Initial commit: lead capture app for small engine repair shops

Vue 3 + TypeScript + Vite + Tailwind CSS v4 multi-step lead capture form
with config-driven white-labeling, externalized content (content.json),
and "starting at" pricing estimates. Mobile-first with camera photo upload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-05-11 11:14:57 -05:00
commit 3b6c296385
36 changed files with 2232 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

40
CLAUDE.md Normal file
View File

@ -0,0 +1,40 @@
# smallengine
## Project Intent
Lead capture application for small engine repair shops (lawnmowers, snowblowers, etc.).
Captures: equipment make/model, mobile image upload, preferred date/time, contact info, and pricing estimate.
Designed to be white-labeled and deployed by many different repair shops with minimal configuration.
## Stack
- Vue 3 with `<script setup>` SFCs
- TypeScript
- Vite 8
- Tailwind CSS for styling
- PWA-capable for mobile image capture
- Fully static — no server backend required
## Architecture Principles
- **Zero server**: all form processing via free SaaS (Formspree, Netlify Forms, or similar)
- **Config-driven**: shop-specific branding, services, and pricing loaded from a JSON config
- **Mobile-first**: responsive Tailwind layout, native camera access for equipment photos
- **Multi-shop scalable**: each shop forks the repo and edits `shop.config.json`
## Commands
- `yarn build` — type-check and build for production
- `yarn preview` — preview production build
- Do NOT run `yarn dev` (per global instructions)
## Conventions
- Use Vue 3 Composition API with `<script setup lang="ts">`
- Source code lives in `src/`
- Use Tailwind utility classes; avoid custom CSS unless necessary
- Components go in `src/components/`
- Types/interfaces go in `src/types/`
- Shop configuration lives in `shop.config.json` at project root
## Decisions
- **Lead delivery**: Deferred — build lead capture UI first, then evaluate backend options
- **Pricing**: Configurable "starting at" ranges per service, defined in `shop.config.json`
- **Scheduling**: Customer preference only — date range for pickup/drop-off, no calendar integration
- **Multi-shop**: Fork-per-shop model — each shop clones repo, edits config, deploys independently
- **Shop owner dashboard**: Deferred — will be needed eventually but not in initial build

123
PLAN.md Normal file
View File

@ -0,0 +1,123 @@
# SmallEngine - Implementation Plan
## Phase 1: Project Foundation
- [x] Scaffold Vue 3 + TypeScript + Vite project
- [x] Initialize git repository
- [x] Install and configure Tailwind CSS v4
- [x] Set up `shop.config.json` schema and loader composable
- [x] Create base layout with responsive Tailwind structure
- [ ] Configure PWA support (vite-plugin-pwa)
## Phase 2: Lead Capture Form
- [x] Design multi-step form flow (equipment > photos > schedule > contact > review)
- [x] **EquipmentStep** — equipment type selector, make/model text inputs, service type selection
- [x] **PhotoUpload** — mobile camera capture + file upload with local preview
- [x] **ScheduleStep** — preferred date range for pickup/drop-off (start date + end date)
- [x] **ContactStep** — name, phone, email, address fields
- [x] **PricingEstimate** — display "starting at" price based on selected service (configurable per shop)
- [x] **ReviewStep** — summary of all inputs before submission
- [x] **SuccessScreen** — confirmation after submission
- [x] Form state management (composable with reactive state)
- [x] Step indicator with progress navigation
## Phase 3: Form Submission & Lead Delivery (DEFERRED)
> Build the UI first, then evaluate backend options (Formspree, Netlify Forms, Google Sheets, etc.)
- [ ] Integrate form backend (TBD)
- [ ] Submit form data as structured JSON
- [ ] Image upload integration (Cloudinary or similar — TBD)
- [ ] Success/error confirmation screens
- [ ] Email notification to shop owner on new lead
## Phase 4: White-Label & Theming
- [x] Load `shop.config.json` at app init
- [x] Apply shop branding (name, logo, primary color) to layout
- [x] Populate equipment types and services from config
- [x] Populate pricing ranges from config
- [x] Generate theme colors from config primary color at runtime (HSL conversion)
## Phase 5: Polish & Mobile
- [ ] Mobile-first responsive testing
- [ ] PWA manifest and service worker
- [ ] Camera access for photo capture on mobile
- [ ] Accessibility audit (ARIA labels, keyboard navigation, color contrast)
- [ ] Loading states and form validation UX
- [ ] SEO meta tags (configurable per shop)
## Phase 6: Deployment & Documentation
- [ ] Netlify deployment configuration (`netlify.toml`)
- [ ] Document fork-and-deploy workflow for shop owners
- [ ] Create example `shop.config.json` with comments
- [ ] Write deployment guide for Netlify, Cloudflare Pages, Vercel
- [ ] Test end-to-end: form submission to email receipt
---
## Deployment Instructions
### Prerequisites
- Node.js 18+
- Yarn package manager
- A free account on one of: Netlify, Cloudflare Pages, or Vercel
- A free Formspree account (or use Netlify Forms if hosting on Netlify)
- A free Cloudinary account (for image uploads)
### Step-by-Step Deployment
1. **Clone and configure**
```bash
git clone <repo-url>
cd smallengine
cp shop.config.example.json shop.config.json
# Edit shop.config.json with your shop details
```
2. **Set up Formspree** (if not using Netlify Forms)
- Create account at formspree.io
- Create a new form
- Copy form endpoint URL into `shop.config.json``formBackend`
3. **Set up Cloudinary** (for image uploads)
- Create account at cloudinary.com
- Get your cloud name from the dashboard
- Add to `shop.config.json``cloudinaryCloudName`
- Create an unsigned upload preset → `cloudinaryUploadPreset`
4. **Build and test locally**
```bash
yarn install
yarn build
yarn preview
# Visit http://localhost:4173
```
5. **Deploy to Netlify**
```bash
# Option A: Netlify CLI
npm i -g netlify-cli
netlify deploy --prod --dir=dist
# Option B: Git-based deploy
# Push to GitHub, connect repo in Netlify dashboard
# Build command: yarn build
# Publish directory: dist
```
6. **Set up custom domain** (optional)
- Add custom domain in hosting provider dashboard
- Update DNS records as instructed
- SSL is automatic on all recommended hosts
### Environment-Specific Notes
- **Netlify Forms**: If hosting on Netlify, you can skip Formspree and use Netlify's built-in form handling (free: 100 submissions/month)
- **Image size**: Cloudinary free tier allows 25 credits/month (~25 transformations or uploads)
- **No backend required**: the entire app runs client-side
---
## Decisions Log
- **Lead delivery**: Deferred until UI is complete
- **Pricing**: Configurable "starting at" ranges per service in `shop.config.json`
- **Scheduling**: Customer preference — date range for pickup/drop-off
- **Multi-shop**: Fork-per-shop (clone repo, edit config, deploy)
- **Dashboard**: Deferred — needed eventually, not in initial build

118
README.md Normal file
View File

@ -0,0 +1,118 @@
# SmallEngine - Lead Capture for Small Engine Repair Shops
A lightweight, mobile-friendly lead capture app for small engine repair businesses. Customers submit their equipment details, photos, preferred service times, and contact information. Shop owners receive leads instantly with pricing estimates.
## Features
- **Equipment Details** — capture make, model, and equipment type (mower, snowblower, chainsaw, etc.)
- **Photo Upload** — mobile camera integration for equipment condition photos
- **Service Scheduling** — date/time preference selection
- **Contact Capture** — name, phone, email, address
- **Pricing Estimates** — configurable rate card displayed to customers
- **White-Label Ready** — configure per-shop branding, services, and pricing via JSON
- **Fully Static** — no server required; deploys to any static host for free
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Framework | Vue 3 + TypeScript |
| Styling | Tailwind CSS |
| Build | Vite 8 |
| Hosting | Netlify / Cloudflare Pages / Vercel (free tier) |
| Form Backend | Formspree or Netlify Forms (free tier) |
| Image Upload | Cloudinary (free tier) |
| Mobile | PWA with camera access |
## Quick Start
```bash
# Install dependencies
yarn install
# Build for production
yarn build
# Preview production build
yarn preview
```
## Configuration
Each shop customizes the app by editing `shop.config.json`:
```json
{
"shopName": "Joe's Small Engine Repair",
"phone": "(555) 123-4567",
"email": "joe@example.com",
"logo": "/assets/logo.png",
"primaryColor": "#16a34a",
"services": [
{ "name": "Tune-Up", "priceRange": "$75 - $95" },
{ "name": "Blade Sharpening", "priceRange": "$25 - $35" },
{ "name": "Oil Change", "priceRange": "$40 - $55" }
],
"equipmentTypes": ["Lawn Mower", "Snowblower", "Chainsaw", "Leaf Blower", "Generator"],
"formBackend": "https://formspree.io/f/YOUR_FORM_ID"
}
```
## Deployment
### Netlify (recommended)
1. Fork this repository
2. Edit `shop.config.json` with your shop details
3. Connect the repo to [Netlify](https://netlify.com)
4. Set build command: `yarn build`
5. Set publish directory: `dist`
6. Deploy — your site is live
### Cloudflare Pages
1. Fork and configure `shop.config.json`
2. Connect to [Cloudflare Pages](https://pages.cloudflare.com)
3. Build command: `yarn build`, output: `dist`
### Manual / Any Static Host
```bash
yarn build
# Upload contents of dist/ to your host
```
## Project Structure
```
smallengine/
├── shop.config.json # Shop-specific configuration
├── src/
│ ├── App.vue # Root component
│ ├── main.ts # App entry point
│ ├── components/ # Vue components
│ │ ├── LeadForm.vue # Main lead capture form
│ │ ├── EquipmentStep.vue
│ │ ├── ScheduleStep.vue
│ │ ├── ContactStep.vue
│ │ └── PricingEstimate.vue
│ ├── types/ # TypeScript interfaces
│ └── composables/ # Shared logic (config loader, form submission)
├── public/ # Static assets
├── index.html
├── tailwind.config.js
└── vite.config.ts
```
## Multi-Shop Scaling
Each repair shop gets their own instance by:
1. Forking this repository
2. Editing `shop.config.json`
3. Deploying to a free static host
No shared infrastructure, no databases, no server costs.
## License
MIT

108
content.json Normal file
View File

@ -0,0 +1,108 @@
{
"app": {
"titleSuffix": "Service Request"
},
"steps": {
"equipment": "Equipment",
"photos": "Photos",
"schedule": "Schedule",
"contact": "Contact",
"review": "Review"
},
"navigation": {
"back": "Back",
"continue": "Continue",
"submit": "Submit Request"
},
"equipment": {
"title": "Equipment Details",
"subtitle": "Tell us about the equipment that needs service.",
"equipmentTypeLabel": "Equipment Type",
"equipmentTypePlaceholder": "Select equipment type...",
"makeLabel": "Make / Brand",
"makePlaceholder": "e.g., Honda, Toro, Husqvarna",
"modelLabel": "Model",
"modelPlaceholder": "e.g., HRX217, Recycler 22",
"serviceLabel": "Service Needed",
"servicePlaceholder": "Select a service...",
"problemLabel": "Describe the Problem",
"problemOptional": "(optional)",
"problemPlaceholder": "What's going on with your equipment? Any symptoms, noises, or issues?"
},
"photos": {
"title": "Photos",
"subtitle": "Add photos of your equipment to help us assess the job.",
"optional": "(optional)",
"uploadPrompt": "Tap to take a photo or upload",
"uploadHint": "JPG, PNG up to 10MB each",
"photoAlt": "Photo"
},
"schedule": {
"title": "Schedule Preference",
"subtitle": "When would you like to bring in or have your equipment picked up?",
"pickupLabel": "Pickup or Drop-off?",
"optionDropoff": "I'll drop it off",
"optionPickup": "Please pick it up",
"optionEither": "Either works",
"earliestDateLabel": "Earliest Date",
"latestDateLabel": "Latest Date",
"notesLabel": "Schedule Notes",
"notesOptional": "(optional)",
"notesPlaceholder": "e.g., Mornings work best, gate code is 1234, etc."
},
"contact": {
"title": "Contact Information",
"subtitle": "How can we reach you about your service request?",
"firstNameLabel": "First Name",
"firstNamePlaceholder": "John",
"lastNameLabel": "Last Name",
"lastNamePlaceholder": "Smith",
"phoneLabel": "Phone",
"phonePlaceholder": "(555) 123-4567",
"emailLabel": "Email",
"emailPlaceholder": "john@example.com",
"addressLabel": "Street Address",
"addressPlaceholder": "123 Main St",
"cityLabel": "City",
"cityPlaceholder": "Anytown",
"stateLabel": "State",
"statePlaceholder": "OH",
"zipLabel": "ZIP",
"zipPlaceholder": "12345"
},
"pricing": {
"startingAt": "Starting at",
"quoteUponInspection": "Quote upon inspection"
},
"review": {
"title": "Review Your Request",
"subtitle": "Please confirm the details below are correct.",
"equipmentSection": "Equipment",
"typeLabel": "Type",
"makeLabel": "Make",
"modelLabel": "Model",
"serviceLabel": "Service",
"estimatedPriceLabel": "Estimated Price",
"photosSection": "Photos",
"scheduleSection": "Schedule",
"preferenceLabel": "Preference",
"dateRangeLabel": "Date Range",
"contactSection": "Contact",
"nameLabel": "Name",
"phoneLabel": "Phone",
"emailLabel": "Email",
"addressLabel": "Address",
"pickupLabels": {
"pickup": "Pickup requested",
"dropoff": "Drop-off",
"either": "Pickup or drop-off"
},
"emptyValue": "—"
},
"success": {
"title": "Request Submitted!",
"message": "Thank you! We've received your service request and will be in touch soon to confirm your appointment.",
"callPrompt": "Questions? Call us at",
"submitAnother": "Submit Another Request"
}
}

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>smallengine</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "smallengine",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.32"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"vite": "^8.0.10",
"vue-tsc": "^3.2.7"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

30
shop.config.json Normal file
View File

@ -0,0 +1,30 @@
{
"shopName": "Dave's Small Engine Repair",
"tagline": "Trusted local service for all your outdoor power equipment",
"phone": "(555) 123-4567",
"email": "davem@example.com",
"address": "123 Main St, Anytown, USA 12345",
"logo": "",
"primaryColor": "#16a34a",
"services": [
{ "name": "Tune-Up", "startingAt": 75, "description": "Complete engine tune-up and inspection" },
{ "name": "Blade Sharpening", "startingAt": 25, "description": "Professional blade sharpening and balancing" },
{ "name": "Oil Change", "startingAt": 40, "description": "Oil and filter replacement" },
{ "name": "Carburetor Repair", "startingAt": 85, "description": "Carburetor cleaning, rebuild, or replacement" },
{ "name": "Winterization", "startingAt": 60, "description": "Prepare equipment for off-season storage" },
{ "name": "Full Service", "startingAt": 120, "description": "Comprehensive service and safety check" },
{ "name": "Other / Not Sure", "startingAt": null, "description": "We'll assess and provide a quote" }
],
"equipmentTypes": [
"Lawn Mower (Push)",
"Lawn Mower (Riding)",
"Snowblower",
"Chainsaw",
"Leaf Blower",
"String Trimmer / Weed Eater",
"Generator",
"Pressure Washer",
"Other"
],
"formBackend": ""
}

63
src/App.vue Normal file
View File

@ -0,0 +1,63 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useShopConfig } from './composables/useShopConfig'
import { useContent } from './composables/useContent'
import LeadForm from './components/LeadForm.vue'
const { config, applyTheme } = useShopConfig()
const { content } = useContent()
onMounted(() => {
applyTheme()
if (config.shopName) {
document.title = `${config.shopName} - ${content.app.titleSuffix}`
}
})
</script>
<template>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-2xl mx-auto px-4 py-4 flex items-center gap-3">
<img
v-if="config.logo"
:src="config.logo"
:alt="config.shopName"
class="h-10 w-10 object-contain"
/>
<div
v-else
class="h-10 w-10 rounded-lg bg-primary flex items-center justify-center text-white font-bold text-lg shrink-0"
>
{{ config.shopName.charAt(0) }}
</div>
<div class="min-w-0">
<h1 class="text-lg font-semibold text-gray-900 truncate">{{ config.shopName }}</h1>
<p v-if="config.tagline" class="text-sm text-gray-500 truncate">{{ config.tagline }}</p>
</div>
</div>
</header>
<!-- Main -->
<main class="max-w-2xl mx-auto px-4 py-6">
<LeadForm />
</main>
<!-- Footer -->
<footer class="border-t border-gray-200 bg-white mt-auto">
<div class="max-w-2xl mx-auto px-4 py-4 text-center text-sm text-gray-400">
<p v-if="config.phone || config.email" class="mb-1">
<a v-if="config.phone" :href="`tel:${config.phone}`" class="text-gray-500 hover:text-primary">
{{ config.phone }}
</a>
<span v-if="config.phone && config.email" class="mx-2">|</span>
<a v-if="config.email" :href="`mailto:${config.email}`" class="text-gray-500 hover:text-primary">
{{ config.email }}
</a>
</p>
<p v-if="config.address" class="text-gray-400">{{ config.address }}</p>
</div>
</footer>
</div>
</template>

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

1
src/assets/vue.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,102 @@
<script setup lang="ts">
import { useLeadForm } from '../composables/useLeadForm'
import { useContent } from '../composables/useContent'
const { formData } = useLeadForm()
const { content } = useContent()
const t = content.contact
</script>
<template>
<div>
<h2 class="text-xl font-semibold text-gray-900 mb-1">{{ t.title }}</h2>
<p class="text-sm text-gray-500 mb-6">{{ t.subtitle }}</p>
<!-- Name -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">{{ t.firstNameLabel }}</label>
<input
v-model="formData.firstName"
type="text"
:placeholder="t.firstNamePlaceholder"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">{{ t.lastNameLabel }}</label>
<input
v-model="formData.lastName"
type="text"
:placeholder="t.lastNamePlaceholder"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors"
/>
</div>
</div>
<!-- Phone & Email -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">{{ t.phoneLabel }}</label>
<input
v-model="formData.phone"
type="tel"
:placeholder="t.phonePlaceholder"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">{{ t.emailLabel }}</label>
<input
v-model="formData.email"
type="email"
:placeholder="t.emailPlaceholder"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors"
/>
</div>
</div>
<!-- Address -->
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 mb-1.5">{{ t.addressLabel }}</label>
<input
v-model="formData.address"
type="text"
:placeholder="t.addressPlaceholder"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors"
/>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div class="col-span-2 sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1.5">{{ t.cityLabel }}</label>
<input
v-model="formData.city"
type="text"
:placeholder="t.cityPlaceholder"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">{{ t.stateLabel }}</label>
<input
v-model="formData.state"
type="text"
:placeholder="t.statePlaceholder"
maxlength="2"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">{{ t.zipLabel }}</label>
<input
v-model="formData.zip"
type="text"
:placeholder="t.zipPlaceholder"
maxlength="10"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors"
/>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,85 @@
<script setup lang="ts">
import { useLeadForm } from '../composables/useLeadForm'
import { useShopConfig } from '../composables/useShopConfig'
import { useContent } from '../composables/useContent'
import PricingEstimate from './PricingEstimate.vue'
const { formData } = useLeadForm()
const { config } = useShopConfig()
const { content } = useContent()
const t = content.equipment
</script>
<template>
<div>
<h2 class="text-xl font-semibold text-gray-900 mb-1">{{ t.title }}</h2>
<p class="text-sm text-gray-500 mb-6">{{ t.subtitle }}</p>
<!-- Equipment Type -->
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 mb-1.5">{{ t.equipmentTypeLabel }}</label>
<select
v-model="formData.equipmentType"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 bg-white focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors"
>
<option value="" disabled>{{ t.equipmentTypePlaceholder }}</option>
<option v-for="type in config.equipmentTypes" :key="type" :value="type">
{{ type }}
</option>
</select>
</div>
<!-- Make & Model -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">{{ t.makeLabel }}</label>
<input
v-model="formData.make"
type="text"
:placeholder="t.makePlaceholder"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">{{ t.modelLabel }}</label>
<input
v-model="formData.model"
type="text"
:placeholder="t.modelPlaceholder"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors"
/>
</div>
</div>
<!-- Service Type -->
<div class="mb-5">
<label class="block text-sm font-medium text-gray-700 mb-1.5">{{ t.serviceLabel }}</label>
<select
v-model="formData.serviceType"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 bg-white focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors"
>
<option value="" disabled>{{ t.servicePlaceholder }}</option>
<option v-for="service in config.services" :key="service.name" :value="service.name">
{{ service.name }}
</option>
</select>
</div>
<!-- Pricing Estimate -->
<PricingEstimate v-if="formData.serviceType" />
<!-- Problem Description -->
<div class="mt-5">
<label class="block text-sm font-medium text-gray-700 mb-1.5">
{{ t.problemLabel }}
<span class="text-gray-400 font-normal">{{ t.problemOptional }}</span>
</label>
<textarea
v-model="formData.problemDescription"
rows="3"
:placeholder="t.problemPlaceholder"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors resize-none"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,80 @@
<script setup lang="ts">
import { useLeadForm } from '../composables/useLeadForm'
import { useContent } from '../composables/useContent'
import StepIndicator from './StepIndicator.vue'
import EquipmentStep from './EquipmentStep.vue'
import PhotoUpload from './PhotoUpload.vue'
import ScheduleStep from './ScheduleStep.vue'
import ContactStep from './ContactStep.vue'
import ReviewStep from './ReviewStep.vue'
import SuccessScreen from './SuccessScreen.vue'
const {
currentStep,
currentStepIndex,
isFirstStep,
isLastStep,
submitted,
steps,
stepLabels,
nextStep,
prevStep,
goToStep,
submitForm,
} = useLeadForm()
const { content } = useContent()
</script>
<template>
<div v-if="submitted" class="animate-fade-in">
<SuccessScreen />
</div>
<div v-else>
<StepIndicator
:steps="[...steps]"
:step-labels="stepLabels"
:current-index="currentStepIndex"
@go-to="goToStep"
/>
<div class="mt-6 bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<EquipmentStep v-if="currentStep === 'equipment'" />
<PhotoUpload v-else-if="currentStep === 'photos'" />
<ScheduleStep v-else-if="currentStep === 'schedule'" />
<ContactStep v-else-if="currentStep === 'contact'" />
<ReviewStep v-else-if="currentStep === 'review'" />
<!-- Navigation -->
<div class="flex justify-between mt-8 pt-6 border-t border-gray-100">
<button
v-if="!isFirstStep"
type="button"
class="px-5 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
@click="prevStep"
>
{{ content.navigation.back }}
</button>
<div v-else />
<button
v-if="!isLastStep"
type="button"
class="px-5 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary-dark transition-colors"
@click="nextStep"
>
{{ content.navigation.continue }}
</button>
<button
v-else
type="button"
class="px-6 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary-dark transition-colors"
@click="submitForm"
>
{{ content.navigation.submit }}
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,80 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useLeadForm } from '../composables/useLeadForm'
import { useContent } from '../composables/useContent'
const { formData, addPhoto, removePhoto } = useLeadForm()
const { content } = useContent()
const t = content.photos
const fileInput = ref<HTMLInputElement>()
function triggerUpload() {
fileInput.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) {
for (const file of input.files) {
if (file.type.startsWith('image/')) {
addPhoto(file)
}
}
input.value = ''
}
}
</script>
<template>
<div>
<h2 class="text-xl font-semibold text-gray-900 mb-1">{{ t.title }}</h2>
<p class="text-sm text-gray-500 mb-6">
{{ t.subtitle }}
<span class="text-gray-400">{{ t.optional }}</span>
</p>
<!-- Upload Area -->
<div
class="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center cursor-pointer hover:border-primary hover:bg-primary-light/10 transition-colors"
@click="triggerUpload"
>
<input
ref="fileInput"
type="file"
accept="image/*"
capture="environment"
multiple
class="hidden"
@change="onFileChange"
/>
<svg class="mx-auto h-10 w-10 text-gray-400 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z" />
</svg>
<p class="text-sm font-medium text-gray-700">{{ t.uploadPrompt }}</p>
<p class="text-xs text-gray-400 mt-1">{{ t.uploadHint }}</p>
</div>
<!-- Photo Previews -->
<div v-if="formData.photos.length > 0" class="mt-5 grid grid-cols-3 gap-3">
<div
v-for="(photo, index) in formData.photos"
:key="index"
class="relative group aspect-square rounded-lg overflow-hidden bg-gray-100"
>
<img
:src="photo.preview"
:alt="`${t.photoAlt} ${index + 1}`"
class="w-full h-full object-cover"
/>
<button
type="button"
class="absolute top-1.5 right-1.5 bg-black/60 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop="removePhoto(index)"
>
&times;
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useLeadForm } from '../composables/useLeadForm'
import { useShopConfig } from '../composables/useShopConfig'
import { useContent } from '../composables/useContent'
const { formData } = useLeadForm()
const { config } = useShopConfig()
const { content } = useContent()
const t = content.pricing
const selectedService = computed(() =>
config.services.find((s) => s.name === formData.serviceType)
)
</script>
<template>
<div
v-if="selectedService"
class="rounded-lg bg-primary-light/20 border border-primary/20 px-4 py-3"
>
<div class="flex items-baseline justify-between">
<div>
<p class="text-sm font-medium text-gray-900">{{ selectedService.name }}</p>
<p class="text-xs text-gray-500 mt-0.5">{{ selectedService.description }}</p>
</div>
<p v-if="selectedService.startingAt != null" class="text-sm font-semibold text-primary whitespace-nowrap ml-4">
{{ t.startingAt }} ${{ selectedService.startingAt }}
</p>
<p v-else class="text-sm text-gray-500 italic whitespace-nowrap ml-4">
{{ t.quoteUponInspection }}
</p>
</div>
</div>
</template>

View File

@ -0,0 +1,117 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useLeadForm } from '../composables/useLeadForm'
import { useShopConfig } from '../composables/useShopConfig'
import { useContent } from '../composables/useContent'
const { formData } = useLeadForm()
const { config } = useShopConfig()
const { content } = useContent()
const t = content.review
const tp = content.pricing
const selectedService = computed(() =>
config.services.find((s) => s.name === formData.serviceType)
)
function formatDate(dateStr: string) {
if (!dateStr) return ''
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
}
</script>
<template>
<div>
<h2 class="text-xl font-semibold text-gray-900 mb-1">{{ t.title }}</h2>
<p class="text-sm text-gray-500 mb-6">{{ t.subtitle }}</p>
<div class="space-y-5">
<!-- Equipment -->
<div class="rounded-lg border border-gray-200 p-4">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">{{ t.equipmentSection }}</h3>
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<dt class="text-gray-500">{{ t.typeLabel }}</dt>
<dd class="text-gray-900">{{ formData.equipmentType || t.emptyValue }}</dd>
<dt class="text-gray-500">{{ t.makeLabel }}</dt>
<dd class="text-gray-900">{{ formData.make || t.emptyValue }}</dd>
<dt class="text-gray-500">{{ t.modelLabel }}</dt>
<dd class="text-gray-900">{{ formData.model || t.emptyValue }}</dd>
<dt class="text-gray-500">{{ t.serviceLabel }}</dt>
<dd class="text-gray-900">{{ formData.serviceType || t.emptyValue }}</dd>
</dl>
<p v-if="formData.problemDescription" class="text-sm text-gray-600 mt-2 border-t border-gray-100 pt-2">
{{ formData.problemDescription }}
</p>
</div>
<!-- Pricing -->
<div v-if="selectedService" class="rounded-lg bg-primary-light/20 border border-primary/20 px-4 py-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-900">{{ t.estimatedPriceLabel }}</span>
<span v-if="selectedService.startingAt != null" class="text-sm font-semibold text-primary">
{{ tp.startingAt }} ${{ selectedService.startingAt }}
</span>
<span v-else class="text-sm text-gray-500 italic">{{ tp.quoteUponInspection }}</span>
</div>
</div>
<!-- Photos -->
<div v-if="formData.photos.length > 0" class="rounded-lg border border-gray-200 p-4">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">
{{ t.photosSection }} ({{ formData.photos.length }})
</h3>
<div class="flex gap-2 overflow-x-auto">
<img
v-for="(photo, i) in formData.photos"
:key="i"
:src="photo.preview"
:alt="`${content.photos.photoAlt} ${i + 1}`"
class="w-16 h-16 rounded-md object-cover shrink-0"
/>
</div>
</div>
<!-- Schedule -->
<div class="rounded-lg border border-gray-200 p-4">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">{{ t.scheduleSection }}</h3>
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<dt class="text-gray-500">{{ t.preferenceLabel }}</dt>
<dd class="text-gray-900">{{ t.pickupLabels[formData.pickupOrDropoff] }}</dd>
<dt class="text-gray-500">{{ t.dateRangeLabel }}</dt>
<dd class="text-gray-900">
<template v-if="formData.preferredDateStart">
{{ formatDate(formData.preferredDateStart) }}
<template v-if="formData.preferredDateEnd"> {{ formatDate(formData.preferredDateEnd) }}</template>
</template>
<template v-else>{{ t.emptyValue }}</template>
</dd>
</dl>
<p v-if="formData.scheduleNotes" class="text-sm text-gray-600 mt-2 border-t border-gray-100 pt-2">
{{ formData.scheduleNotes }}
</p>
</div>
<!-- Contact -->
<div class="rounded-lg border border-gray-200 p-4">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">{{ t.contactSection }}</h3>
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<dt class="text-gray-500">{{ t.nameLabel }}</dt>
<dd class="text-gray-900">{{ formData.firstName }} {{ formData.lastName }}</dd>
<dt class="text-gray-500">{{ t.phoneLabel }}</dt>
<dd class="text-gray-900">{{ formData.phone || t.emptyValue }}</dd>
<dt class="text-gray-500">{{ t.emailLabel }}</dt>
<dd class="text-gray-900">{{ formData.email || t.emptyValue }}</dd>
<dt class="text-gray-500">{{ t.addressLabel }}</dt>
<dd class="text-gray-900">
<template v-if="formData.address">
{{ formData.address }}<br />
{{ formData.city }}, {{ formData.state }} {{ formData.zip }}
</template>
<template v-else>{{ t.emptyValue }}</template>
</dd>
</dl>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useLeadForm } from '../composables/useLeadForm'
import { useContent } from '../composables/useContent'
const { formData } = useLeadForm()
const { content } = useContent()
const t = content.schedule
const today = computed(() => {
const d = new Date()
return d.toISOString().split('T')[0]
})
const pickupOptions = computed(() => [
{ value: 'dropoff', label: t.optionDropoff },
{ value: 'pickup', label: t.optionPickup },
{ value: 'either', label: t.optionEither },
])
</script>
<template>
<div>
<h2 class="text-xl font-semibold text-gray-900 mb-1">{{ t.title }}</h2>
<p class="text-sm text-gray-500 mb-6">{{ t.subtitle }}</p>
<!-- Pickup or Drop-off -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-2">{{ t.pickupLabel }}</label>
<div class="flex gap-3">
<label
v-for="option in pickupOptions"
:key="option.value"
class="flex-1 relative cursor-pointer"
>
<input
v-model="formData.pickupOrDropoff"
type="radio"
:value="option.value"
class="peer sr-only"
/>
<div
class="text-center px-3 py-2.5 rounded-lg border text-sm font-medium transition-colors peer-checked:border-primary peer-checked:bg-primary-light/20 peer-checked:text-primary border-gray-300 text-gray-700 hover:bg-gray-50"
>
{{ option.label }}
</div>
</label>
</div>
</div>
<!-- Date Range -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">{{ t.earliestDateLabel }}</label>
<input
v-model="formData.preferredDateStart"
type="date"
:min="today"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">{{ t.latestDateLabel }}</label>
<input
v-model="formData.preferredDateEnd"
type="date"
:min="formData.preferredDateStart || today"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors"
/>
</div>
</div>
<!-- Notes -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">
{{ t.notesLabel }}
<span class="text-gray-400 font-normal">{{ t.notesOptional }}</span>
</label>
<textarea
v-model="formData.scheduleNotes"
rows="2"
:placeholder="t.notesPlaceholder"
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 focus:border-primary focus:ring-1 focus:ring-primary outline-none transition-colors resize-none"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import type { StepName } from '../composables/useLeadForm'
defineProps<{
steps: StepName[]
stepLabels: Record<StepName, string>
currentIndex: number
}>()
const emit = defineEmits<{
goTo: [index: number]
}>()
</script>
<template>
<nav class="flex items-center justify-between gap-1 sm:gap-2">
<button
v-for="(step, index) in steps"
:key="step"
type="button"
class="flex-1 group"
:class="index <= currentIndex ? 'cursor-pointer' : 'cursor-default'"
@click="index <= currentIndex && emit('goTo', index)"
>
<div
class="h-1.5 rounded-full mb-1.5 transition-colors"
:class="
index < currentIndex
? 'bg-primary'
: index === currentIndex
? 'bg-primary'
: 'bg-gray-200'
"
/>
<span
class="text-xs font-medium transition-colors hidden sm:inline"
:class="
index <= currentIndex ? 'text-primary' : 'text-gray-400'
"
>
{{ stepLabels[step] }}
</span>
</button>
</nav>
</template>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import { useLeadForm } from '../composables/useLeadForm'
import { useShopConfig } from '../composables/useShopConfig'
import { useContent } from '../composables/useContent'
const { resetForm } = useLeadForm()
const { config } = useShopConfig()
const { content } = useContent()
const t = content.success
</script>
<template>
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
<div class="mx-auto w-14 h-14 rounded-full bg-primary-light/30 flex items-center justify-center mb-4">
<svg class="w-7 h-7 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 class="text-xl font-semibold text-gray-900 mb-2">{{ t.title }}</h2>
<p class="text-sm text-gray-500 mb-6 max-w-sm mx-auto">{{ t.message }}</p>
<div v-if="config.phone" class="text-sm text-gray-600 mb-6">
{{ t.callPrompt }}
<a :href="`tel:${config.phone}`" class="text-primary font-medium hover:underline">
{{ config.phone }}
</a>
</div>
<button
type="button"
class="px-5 py-2.5 text-sm font-medium text-primary border border-primary rounded-lg hover:bg-primary-light/20 transition-colors"
@click="resetForm"
>
{{ t.submitAnother }}
</button>
</div>
</template>

View File

@ -0,0 +1,11 @@
import { readonly, reactive } from 'vue'
import contentData from '../../content.json'
type Content = typeof contentData
const content = reactive(structuredClone(contentData)) as Content
export function useContent() {
return {
content: readonly(content) as Readonly<Content>,
}
}

View File

@ -0,0 +1,82 @@
import { reactive, computed } from 'vue'
import { createEmptyLead, type LeadFormData } from '../types/lead'
import { useContent } from './useContent'
const STEPS = ['equipment', 'photos', 'schedule', 'contact', 'review'] as const
export type StepName = (typeof STEPS)[number]
const formData = reactive<LeadFormData>(createEmptyLead())
const currentStepIndex = reactive({ value: 0 })
const submitted = reactive({ value: false })
export function useLeadForm() {
const { content } = useContent()
const stepLabels = content.steps as Record<StepName, string>
const currentStep = computed(() => STEPS[currentStepIndex.value])
const isFirstStep = computed(() => currentStepIndex.value === 0)
const isLastStep = computed(() => currentStepIndex.value === STEPS.length - 1)
const progress = computed(() => ((currentStepIndex.value + 1) / STEPS.length) * 100)
function nextStep() {
if (currentStepIndex.value < STEPS.length - 1) {
currentStepIndex.value++
}
}
function prevStep() {
if (currentStepIndex.value > 0) {
currentStepIndex.value--
}
}
function goToStep(index: number) {
if (index >= 0 && index < STEPS.length) {
currentStepIndex.value = index
}
}
function addPhoto(file: File) {
const preview = URL.createObjectURL(file)
formData.photos.push({ file, preview })
}
function removePhoto(index: number) {
const photo = formData.photos[index]
if (photo) {
URL.revokeObjectURL(photo.preview)
formData.photos.splice(index, 1)
}
}
function resetForm() {
Object.assign(formData, createEmptyLead())
currentStepIndex.value = 0
submitted.value = false
}
async function submitForm() {
// TODO: integrate with form backend (Formspree, Netlify Forms, etc.)
console.log('Lead submitted:', JSON.parse(JSON.stringify(formData)))
submitted.value = true
}
return {
formData,
currentStep,
currentStepIndex: computed(() => currentStepIndex.value),
isFirstStep,
isLastStep,
progress,
submitted: computed(() => submitted.value),
steps: STEPS,
stepLabels,
nextStep,
prevStep,
goToStep,
addPhoto,
removePhoto,
resetForm,
submitForm,
}
}

View File

@ -0,0 +1,61 @@
import { reactive, readonly } from 'vue'
import type { ShopConfig } from '../types/shop'
import configData from '../../shop.config.json'
const config = reactive<ShopConfig>(configData as ShopConfig)
export function useShopConfig() {
function applyTheme() {
const root = document.documentElement
root.style.setProperty('--shop-primary', config.primaryColor)
// Generate darker/lighter variants from hex
root.style.setProperty('--shop-primary-dark', darken(config.primaryColor, 0.15))
root.style.setProperty('--shop-primary-light', lighten(config.primaryColor, 0.7))
}
return {
config: readonly(config),
applyTheme,
}
}
function hexToHsl(hex: string): [number, number, number] {
const r = parseInt(hex.slice(1, 3), 16) / 255
const g = parseInt(hex.slice(3, 5), 16) / 255
const b = parseInt(hex.slice(5, 7), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const l = (max + min) / 2
let h = 0
let s = 0
if (max !== min) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6
else if (max === g) h = ((b - r) / d + 2) / 6
else h = ((r - g) / d + 4) / 6
}
return [h * 360, s * 100, l * 100]
}
function hslToHex(h: number, s: number, l: number): string {
s /= 100
l /= 100
const a = s * Math.min(l, 1 - l)
const f = (n: number) => {
const k = (n + h / 30) % 12
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
return Math.round(255 * color).toString(16).padStart(2, '0')
}
return `#${f(0)}${f(8)}${f(4)}`
}
function darken(hex: string, amount: number): string {
const [h, s, l] = hexToHsl(hex)
return hslToHex(h, s, Math.max(0, l - amount * 100))
}
function lighten(hex: string, amount: number): string {
const [h, s, l] = hexToHsl(hex)
return hslToHex(h, s, Math.min(100, l + amount * 100))
}

5
src/main.ts Normal file
View File

@ -0,0 +1,5 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')

7
src/style.css Normal file
View File

@ -0,0 +1,7 @@
@import "tailwindcss";
@theme {
--color-primary: var(--shop-primary, #16a34a);
--color-primary-dark: var(--shop-primary-dark, #15803d);
--color-primary-light: var(--shop-primary-light, #bbf7d0);
}

55
src/types/lead.ts Normal file
View File

@ -0,0 +1,55 @@
export interface LeadFormData {
// Equipment
equipmentType: string
make: string
model: string
serviceType: string
problemDescription: string
// Photos
photos: PhotoFile[]
// Schedule
preferredDateStart: string
preferredDateEnd: string
pickupOrDropoff: 'pickup' | 'dropoff' | 'either'
scheduleNotes: string
// Contact
firstName: string
lastName: string
phone: string
email: string
address: string
city: string
state: string
zip: string
}
export interface PhotoFile {
file: File
preview: string
}
export function createEmptyLead(): LeadFormData {
return {
equipmentType: '',
make: '',
model: '',
serviceType: '',
problemDescription: '',
photos: [],
preferredDateStart: '',
preferredDateEnd: '',
pickupOrDropoff: 'either',
scheduleNotes: '',
firstName: '',
lastName: '',
phone: '',
email: '',
address: '',
city: '',
state: '',
zip: '',
}
}

18
src/types/shop.ts Normal file
View File

@ -0,0 +1,18 @@
export interface Service {
name: string
startingAt: number | null
description: string
}
export interface ShopConfig {
shopName: string
tagline: string
phone: string
email: string
address: string
logo: string
primaryColor: string
services: Service[]
equipmentTypes: string[]
formBackend: string
}

14
tsconfig.app.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

8
vite.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
})

699
yarn.lock Normal file
View File

@ -0,0 +1,699 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
"@babel/helper-string-parser@^7.27.1":
version "7.27.1"
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687"
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
"@babel/helper-validator-identifier@^7.28.5":
version "7.28.5"
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4"
integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
"@babel/parser@^7.29.3":
version "7.29.3"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.3.tgz#116f70a77958307fceac27747573032f8a62f88e"
integrity sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==
dependencies:
"@babel/types" "^7.29.0"
"@babel/types@^7.29.0":
version "7.29.0"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7"
integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==
dependencies:
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.28.5"
"@emnapi/core@1.10.0", "@emnapi/core@^1.10.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.10.0.tgz#380ccc8f2412ea22d1d972df7f8ee23a3b9c7467"
integrity sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==
dependencies:
"@emnapi/wasi-threads" "1.2.1"
tslib "^2.4.0"
"@emnapi/runtime@1.10.0", "@emnapi/runtime@^1.10.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.10.0.tgz#4b260c0d3534204e98c6110b8db1a987d26ec87c"
integrity sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==
dependencies:
tslib "^2.4.0"
"@emnapi/wasi-threads@1.2.1", "@emnapi/wasi-threads@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548"
integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==
dependencies:
tslib "^2.4.0"
"@jridgewell/gen-mapping@^0.3.5":
version "0.3.13"
resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f"
integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.0"
"@jridgewell/trace-mapping" "^0.3.24"
"@jridgewell/remapping@^2.3.5":
version "2.3.5"
resolved "https://registry.yarnpkg.com/@jridgewell/remapping/-/remapping-2.3.5.tgz#375c476d1972947851ba1e15ae8f123047445aa1"
integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==
dependencies:
"@jridgewell/gen-mapping" "^0.3.5"
"@jridgewell/trace-mapping" "^0.3.24"
"@jridgewell/resolve-uri@^3.1.0":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6"
integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5":
version "1.5.5"
resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba"
integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
"@jridgewell/trace-mapping@^0.3.24":
version "0.3.31"
resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0"
integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==
dependencies:
"@jridgewell/resolve-uri" "^3.1.0"
"@jridgewell/sourcemap-codec" "^1.4.14"
"@napi-rs/wasm-runtime@^1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz#a46bbfedc29751b7170c5d23bc1d8ee8c7e3c1e1"
integrity sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==
dependencies:
"@tybys/wasm-util" "^0.10.1"
"@oxc-project/types@=0.128.0":
version "0.128.0"
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.128.0.tgz#efc7524f948ff9e8ab1404ecad1823849c6fe149"
integrity sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==
"@rolldown/binding-android-arm64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz#3af8b2242086125934a85c1915b76e0a6a2054c1"
integrity sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==
"@rolldown/binding-darwin-arm64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz#ae0b4467d24ecd6c6589f03d4d4699616ee9649c"
integrity sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==
"@rolldown/binding-darwin-x64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz#23cf24b0a7b96c8990bbdd8a91e7fd3ba82b00e7"
integrity sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==
"@rolldown/binding-freebsd-x64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz#a047a770f94dc451c062b729e5d1cf82e5c6f9c4"
integrity sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz#c0b7f346cbf50301cea669a4632bc63aabe6a72c"
integrity sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz#af56373c7996ebe6379207cd699c9f7f705e235d"
integrity sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz#a8f5acd21fcffc8991aa84710e3ae603c4240ea4"
integrity sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz#1d4a89e040ff82141fc46e717cfab80b05f7c13f"
integrity sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz#97c21feeb2ed87d07820f0b2dcc5dd663e7a7f3b"
integrity sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz#06310d40fe139ccc3c433b361120d337c66ebec2"
integrity sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==
"@rolldown/binding-linux-x64-musl@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz#6a711258841f42609b238050cfcd5db13ac136d0"
integrity sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==
"@rolldown/binding-openharmony-arm64@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz#15cb644beeafdbec930d79ed45c2a7c2573eac70"
integrity sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==
"@rolldown/binding-wasm32-wasi@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz#ca3a56d11dfd533d743711141b3bb4c1ec10110e"
integrity sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==
dependencies:
"@emnapi/core" "1.10.0"
"@emnapi/runtime" "1.10.0"
"@napi-rs/wasm-runtime" "^1.1.4"
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz#8c2117d68331d7de59d24631146d538fc203d27c"
integrity sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz#bb5c28df3095046778cc1b020ef52fc5ee7b7e70"
integrity sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==
"@rolldown/pluginutils@1.0.0-rc.13":
version "1.0.0-rc.13"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz#093a01af0cde13552f058544fcadf12e9b522c3b"
integrity sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==
"@rolldown/pluginutils@1.0.0-rc.18":
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz#51cf2589596a179ebe8cbf313f1358c7b51a2fdc"
integrity sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==
"@tailwindcss/node@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/node/-/node-4.3.0.tgz#9dc5312bf41c48658529f36021e0b466c4eb7860"
integrity sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==
dependencies:
"@jridgewell/remapping" "^2.3.5"
enhanced-resolve "^5.21.0"
jiti "^2.6.1"
lightningcss "1.32.0"
magic-string "^0.30.21"
source-map-js "^1.2.1"
tailwindcss "4.3.0"
"@tailwindcss/oxide-android-arm64@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz#e4533b6125236fe81a899cf5a82028c85244def8"
integrity sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==
"@tailwindcss/oxide-darwin-arm64@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz#96b074ef64ec6c41d580063740c8d36cf5c459ce"
integrity sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==
"@tailwindcss/oxide-darwin-x64@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz#0d9638d06d38684339b2dc06631966a7296bb64e"
integrity sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==
"@tailwindcss/oxide-freebsd-x64@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz#efc7acd17cd38d7585c07cb938a4f1b703f79d7a"
integrity sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==
"@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz#e41c945e529670cd93fd6ed0c6a2880de5c40333"
integrity sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==
"@tailwindcss/oxide-linux-arm64-gnu@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz#6bb608b16ba7146d61097c2f4c7ee927d1f3580a"
integrity sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==
"@tailwindcss/oxide-linux-arm64-musl@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz#1bb443aa371bb99b50cb39d4d688151fadcd8a63"
integrity sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==
"@tailwindcss/oxide-linux-x64-gnu@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz#5267c0bb2597426c0d2e759acb5389cde2aa71fd"
integrity sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==
"@tailwindcss/oxide-linux-x64-musl@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz#fb2da97c67b218e5c7c723cb32782d55d7e4a5d5"
integrity sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==
"@tailwindcss/oxide-wasm32-wasi@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz#3f6538e511066d67d8683863dcaeeb16c22de849"
integrity sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==
dependencies:
"@emnapi/core" "^1.10.0"
"@emnapi/runtime" "^1.10.0"
"@emnapi/wasi-threads" "^1.2.1"
"@napi-rs/wasm-runtime" "^1.1.4"
"@tybys/wasm-util" "^0.10.1"
tslib "^2.8.1"
"@tailwindcss/oxide-win32-arm64-msvc@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz#ec45fba773c76759338c05d4fe5cf42c4eea2e4e"
integrity sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==
"@tailwindcss/oxide-win32-x64-msvc@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz#58cdd6e06adbe2e3160274edfcd0b0b43e17fee4"
integrity sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==
"@tailwindcss/oxide@4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/oxide/-/oxide-4.3.0.tgz#cc1c61e88f62c0e9f56062de3e7873acaa2159d4"
integrity sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==
optionalDependencies:
"@tailwindcss/oxide-android-arm64" "4.3.0"
"@tailwindcss/oxide-darwin-arm64" "4.3.0"
"@tailwindcss/oxide-darwin-x64" "4.3.0"
"@tailwindcss/oxide-freebsd-x64" "4.3.0"
"@tailwindcss/oxide-linux-arm-gnueabihf" "4.3.0"
"@tailwindcss/oxide-linux-arm64-gnu" "4.3.0"
"@tailwindcss/oxide-linux-arm64-musl" "4.3.0"
"@tailwindcss/oxide-linux-x64-gnu" "4.3.0"
"@tailwindcss/oxide-linux-x64-musl" "4.3.0"
"@tailwindcss/oxide-wasm32-wasi" "4.3.0"
"@tailwindcss/oxide-win32-arm64-msvc" "4.3.0"
"@tailwindcss/oxide-win32-x64-msvc" "4.3.0"
"@tailwindcss/vite@^4.3.0":
version "4.3.0"
resolved "https://registry.yarnpkg.com/@tailwindcss/vite/-/vite-4.3.0.tgz#b2bbc069a4c700ea7aef5ee30416d84b7652e136"
integrity sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==
dependencies:
"@tailwindcss/node" "4.3.0"
"@tailwindcss/oxide" "4.3.0"
tailwindcss "4.3.0"
"@tybys/wasm-util@^0.10.1":
version "0.10.2"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz#12b3a1b33db1f9cad4ddff1f604ab7dd00bf464e"
integrity sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==
dependencies:
tslib "^2.4.0"
"@types/node@^24.12.2":
version "24.12.3"
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.12.3.tgz#c7e80a5ac6d7438bca394d95ee982b705b94e460"
integrity sha512-8oljBDGun9cIsZRJR6fkihn0TSXJI0UDOOhncYaERq6M0JMDoPLxyscwruJcb4GKS6dvK/d8xebYBg27h/duaQ==
dependencies:
undici-types "~7.16.0"
"@vitejs/plugin-vue@^6.0.6":
version "6.0.6"
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz#4d8a3a7941382cf2760ac5593364bea6856ae7b2"
integrity sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==
dependencies:
"@rolldown/pluginutils" "1.0.0-rc.13"
"@volar/language-core@2.4.28":
version "2.4.28"
resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.4.28.tgz#c21f365a91c1dffe8bd7264fd491770c8d74fef3"
integrity sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==
dependencies:
"@volar/source-map" "2.4.28"
"@volar/source-map@2.4.28":
version "2.4.28"
resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.4.28.tgz#b40254e8c96199e5f1e0796777c593c617ad270e"
integrity sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==
"@volar/typescript@2.4.28":
version "2.4.28"
resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.4.28.tgz#83f86356e84eb101b8081a44c104f2f2ced8411f"
integrity sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==
dependencies:
"@volar/language-core" "2.4.28"
path-browserify "^1.0.1"
vscode-uri "^3.0.8"
"@vue/compiler-core@3.5.34":
version "3.5.34"
resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.34.tgz#6d84a46b7fdf1162cf8225aa2be42918a76ab827"
integrity sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==
dependencies:
"@babel/parser" "^7.29.3"
"@vue/shared" "3.5.34"
entities "^7.0.1"
estree-walker "^2.0.2"
source-map-js "^1.2.1"
"@vue/compiler-dom@3.5.34", "@vue/compiler-dom@^3.5.0":
version "3.5.34"
resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz#6b943e8106822868e74d66c615432bbba6a589be"
integrity sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==
dependencies:
"@vue/compiler-core" "3.5.34"
"@vue/shared" "3.5.34"
"@vue/compiler-sfc@3.5.34":
version "3.5.34"
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz#93b6aeb3393cd7b3c71ff07f28879558f72e5f1d"
integrity sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==
dependencies:
"@babel/parser" "^7.29.3"
"@vue/compiler-core" "3.5.34"
"@vue/compiler-dom" "3.5.34"
"@vue/compiler-ssr" "3.5.34"
"@vue/shared" "3.5.34"
estree-walker "^2.0.2"
magic-string "^0.30.21"
postcss "^8.5.14"
source-map-js "^1.2.1"
"@vue/compiler-ssr@3.5.34":
version "3.5.34"
resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz#0561ae3f9b81564929a8544769eee9cc92a76c42"
integrity sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==
dependencies:
"@vue/compiler-dom" "3.5.34"
"@vue/shared" "3.5.34"
"@vue/language-core@3.2.8":
version "3.2.8"
resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-3.2.8.tgz#3bd38c343b89976208b7996bc670df56313047de"
integrity sha512-9OiSPQFiAAWNVnXb0d2dcTmcKnFQamhuNES6ayyISrb/mwPWVgoGdAqSfCWqKhQpa3D5gDTcYD+w7ObiheZ81g==
dependencies:
"@volar/language-core" "2.4.28"
"@vue/compiler-dom" "^3.5.0"
"@vue/shared" "^3.5.0"
alien-signals "^3.1.2"
muggle-string "^0.4.1"
path-browserify "^1.0.1"
picomatch "^4.0.4"
"@vue/reactivity@3.5.34":
version "3.5.34"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.34.tgz#d41355f7e8b1784078ea498ec7d974e4e26d4b74"
integrity sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==
dependencies:
"@vue/shared" "3.5.34"
"@vue/runtime-core@3.5.34":
version "3.5.34"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.34.tgz#1b4ce2ebf94d670acbd8f22d92e45908e2a2c96a"
integrity sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==
dependencies:
"@vue/reactivity" "3.5.34"
"@vue/shared" "3.5.34"
"@vue/runtime-dom@3.5.34":
version "3.5.34"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz#1b4e5c009fe9d6ce682cfc2372da4abd40614d29"
integrity sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==
dependencies:
"@vue/reactivity" "3.5.34"
"@vue/runtime-core" "3.5.34"
"@vue/shared" "3.5.34"
csstype "^3.2.3"
"@vue/server-renderer@3.5.34":
version "3.5.34"
resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.34.tgz#e0776f839312b4111fb5bd742a70f435298a3e21"
integrity sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==
dependencies:
"@vue/compiler-ssr" "3.5.34"
"@vue/shared" "3.5.34"
"@vue/shared@3.5.34", "@vue/shared@^3.5.0":
version "3.5.34"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.34.tgz#665f2b2fd600f6c180668423909a6fde64cbfccd"
integrity sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==
"@vue/tsconfig@^0.9.1":
version "0.9.1"
resolved "https://registry.yarnpkg.com/@vue/tsconfig/-/tsconfig-0.9.1.tgz#6aa901e9f89242b26e1e564c98747278df6882e5"
integrity sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==
alien-signals@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/alien-signals/-/alien-signals-3.1.2.tgz#26e623e3ed81e401df1a7c503f726e2288a4fa02"
integrity sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==
csstype@^3.2.3:
version "3.2.3"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
detect-libc@^2.0.3:
version "2.1.2"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
enhanced-resolve@^5.21.0:
version "5.21.2"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz#ddbedd0c7f14c3c51adfc24f5a14d76a83395442"
integrity sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==
dependencies:
graceful-fs "^4.2.4"
tapable "^2.3.3"
entities@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b"
integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==
estree-walker@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac"
integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==
fdir@^6.5.0:
version "6.5.0"
resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350"
integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==
fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
graceful-fs@^4.2.4:
version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
jiti@^2.6.1:
version "2.7.0"
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.7.0.tgz#974228f2f4ca2bc21885a1797b45fea68e950c64"
integrity sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==
lightningcss-android-arm64@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz#f033885116dfefd9c6f54787523e3514b61e1968"
integrity sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==
lightningcss-darwin-arm64@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz#50b71871b01c8199584b649e292547faea7af9b5"
integrity sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==
lightningcss-darwin-x64@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz#35f3e97332d130b9ca181e11b568ded6aebc6d5e"
integrity sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==
lightningcss-freebsd-x64@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz#9777a76472b64ed6ff94342ad64c7bafd794a575"
integrity sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==
lightningcss-linux-arm-gnueabihf@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz#13ae652e1ab73b9135d7b7da172f666c410ad53d"
integrity sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==
lightningcss-linux-arm64-gnu@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz#417858795a94592f680123a1b1f9da8a0e1ef335"
integrity sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==
lightningcss-linux-arm64-musl@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz#6be36692e810b718040802fd809623cffe732133"
integrity sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==
lightningcss-linux-x64-gnu@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz#0b7803af4eb21cfd38dd39fe2abbb53c7dd091f6"
integrity sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==
lightningcss-linux-x64-musl@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz#88dc8ba865ddddb1ac5ef04b0f161804418c163b"
integrity sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==
lightningcss-win32-arm64-msvc@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz#4f30ba3fa5e925f5b79f945e8cc0d176c3b1ab38"
integrity sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==
lightningcss-win32-x64-msvc@1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz#141aa5605645064928902bb4af045fa7d9f4220a"
integrity sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==
lightningcss@1.32.0, lightningcss@^1.32.0:
version "1.32.0"
resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.32.0.tgz#b85aae96486dcb1bf49a7c8571221273f4f1e4a9"
integrity sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==
dependencies:
detect-libc "^2.0.3"
optionalDependencies:
lightningcss-android-arm64 "1.32.0"
lightningcss-darwin-arm64 "1.32.0"
lightningcss-darwin-x64 "1.32.0"
lightningcss-freebsd-x64 "1.32.0"
lightningcss-linux-arm-gnueabihf "1.32.0"
lightningcss-linux-arm64-gnu "1.32.0"
lightningcss-linux-arm64-musl "1.32.0"
lightningcss-linux-x64-gnu "1.32.0"
lightningcss-linux-x64-musl "1.32.0"
lightningcss-win32-arm64-msvc "1.32.0"
lightningcss-win32-x64-msvc "1.32.0"
magic-string@^0.30.21:
version "0.30.21"
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"
integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
dependencies:
"@jridgewell/sourcemap-codec" "^1.5.5"
muggle-string@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/muggle-string/-/muggle-string-0.4.1.tgz#3b366bd43b32f809dc20659534dd30e7c8a0d328"
integrity sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==
nanoid@^3.3.11:
version "3.3.12"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.12.tgz#ab3d912e217a6d0a514f00a72a16543a28982c05"
integrity sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==
path-browserify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd"
integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==
picocolors@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
picomatch@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
postcss@^8.5.14:
version "8.5.14"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.14.tgz#a66c2d7808fadf69ebb5b84a03f8bafd76c4919c"
integrity sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==
dependencies:
nanoid "^3.3.11"
picocolors "^1.1.1"
source-map-js "^1.2.1"
rolldown@1.0.0-rc.18:
version "1.0.0-rc.18"
resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.0-rc.18.tgz#c597f89a4ce12e6fc918fa91e4f892b340aa92f0"
integrity sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==
dependencies:
"@oxc-project/types" "=0.128.0"
"@rolldown/pluginutils" "1.0.0-rc.18"
optionalDependencies:
"@rolldown/binding-android-arm64" "1.0.0-rc.18"
"@rolldown/binding-darwin-arm64" "1.0.0-rc.18"
"@rolldown/binding-darwin-x64" "1.0.0-rc.18"
"@rolldown/binding-freebsd-x64" "1.0.0-rc.18"
"@rolldown/binding-linux-arm-gnueabihf" "1.0.0-rc.18"
"@rolldown/binding-linux-arm64-gnu" "1.0.0-rc.18"
"@rolldown/binding-linux-arm64-musl" "1.0.0-rc.18"
"@rolldown/binding-linux-ppc64-gnu" "1.0.0-rc.18"
"@rolldown/binding-linux-s390x-gnu" "1.0.0-rc.18"
"@rolldown/binding-linux-x64-gnu" "1.0.0-rc.18"
"@rolldown/binding-linux-x64-musl" "1.0.0-rc.18"
"@rolldown/binding-openharmony-arm64" "1.0.0-rc.18"
"@rolldown/binding-wasm32-wasi" "1.0.0-rc.18"
"@rolldown/binding-win32-arm64-msvc" "1.0.0-rc.18"
"@rolldown/binding-win32-x64-msvc" "1.0.0-rc.18"
source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
tailwindcss@4.3.0, tailwindcss@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.3.0.tgz#0a874e044a859cf6de413f3a59e76a9bedf05264"
integrity sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==
tapable@^2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.3.tgz#5da7c9992c46038221267985ab28421a8879f160"
integrity sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==
tinyglobby@^0.2.16:
version "0.2.16"
resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.16.tgz#1c3b7eb953fce42b226bc5a1ee06428281aff3d6"
integrity sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==
dependencies:
fdir "^6.5.0"
picomatch "^4.0.4"
tslib@^2.4.0, tslib@^2.8.1:
version "2.8.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
typescript@~6.0.2:
version "6.0.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21"
integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==
undici-types@~7.16.0:
version "7.16.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
vite@^8.0.10:
version "8.0.11"
resolved "https://registry.yarnpkg.com/vite/-/vite-8.0.11.tgz#d128fe82a0dd24da5127d20560735f1cd7ade0a6"
integrity sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==
dependencies:
lightningcss "^1.32.0"
picomatch "^4.0.4"
postcss "^8.5.14"
rolldown "1.0.0-rc.18"
tinyglobby "^0.2.16"
optionalDependencies:
fsevents "~2.3.3"
vscode-uri@^3.0.8:
version "3.1.0"
resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.1.0.tgz#dd09ec5a66a38b5c3fffc774015713496d14e09c"
integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==
vue-tsc@^3.2.7:
version "3.2.8"
resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-3.2.8.tgz#92e6190f198b460c92b35f4f66eb791e374f0c01"
integrity sha512-27vTLJ6Q2370obOd0PFYoYoKnmXJ521uUIedrs3Zhhhg/8YG10VOCMmwt+JQslatpAMTDbnWiitLnoD5VlIvog==
dependencies:
"@volar/typescript" "2.4.28"
"@vue/language-core" "3.2.8"
vue@^3.5.32:
version "3.5.34"
resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.34.tgz#3b256eb30964416af6406a795fcfd7a5773a93c5"
integrity sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==
dependencies:
"@vue/compiler-dom" "3.5.34"
"@vue/compiler-sfc" "3.5.34"
"@vue/runtime-dom" "3.5.34"
"@vue/server-renderer" "3.5.34"
"@vue/shared" "3.5.34"