portal: Vue 3 + Tailwind + shadcn-vue rewrite of www.gigafibre.ca (preview at /next)

Pixel-identical port of the React marketing site: Tailwind config, HSL theme
tokens, fonts (Plus Jakarta Sans / Space Grotesk) and custom utilities copied
verbatim; shadcn-vue (reka-ui) components; all 33 routes ported. Served at
www.gigafibre.ca/next (noindex) behind nginx, ahead of replacing the React site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-06 21:52:14 -04:00
parent 224db4527e
commit 3c7f04870c
353 changed files with 21392 additions and 0 deletions

7
apps/website-vue/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules/
dist/
dist-ssr/
*.local
.DS_Store
.vite/
*.log

View File

@ -0,0 +1,79 @@
# React → Vue port conventions (gigafibre website)
We are porting the React site at `../website` (React 18 + Vite + Tailwind 3 + shadcn/ui + Radix)
to this Vue 3 app (`../website-vue`, Vue 3 `<script setup>` + Vite + Tailwind 3 + shadcn-vue + reka-ui).
**Goal: pixel-identical output.** The Tailwind config, CSS tokens, fonts and custom utilities are already
copied verbatim, so **keep the exact same Tailwind class strings** on every element.
## The golden rule
Copy the JSX markup and its `className` strings **unchanged** into the Vue `<template>` (rename `className`→`class`).
The visual result comes from those classes; do not "improve" or restructure them.
## Mechanical mappings
| React | Vue |
|---|---|
| `className="..."` | `class="..."` |
| `className={cn(a, b)}` | `:class="cn(a, b)"` (import `cn` from `@/lib/utils`) |
| `{expr}` in JSX text | `{{ expr }}` |
| `{items.map(x => <Card .../>)}` | `<Card v-for="x in items" :key="x.id" ... />` |
| `{cond && <X/>}` | `<X v-if="cond" />` |
| `{cond ? <A/> : <B/>}` | `<A v-if="cond" /> <B v-else />` |
| `onClick={fn}` | `@click="fn"` |
| `onChange={e => setX(e.target.value)}` | `v-model="x"` on inputs |
| `useState(v)` | `const x = ref(v)` (`.value` in script, bare in template) |
| `useEffect(fn, [])` | `onMounted(fn)` |
| `useEffect(fn, [dep])` | `watch(() => dep, fn)` |
| `<Link to="/x">` (react-router) | `<RouterLink to="/x">` |
| `useNavigate()``navigate("/x")` | `const router = useRouter(); router.push("/x")` |
| `useLocation()` | `const route = useRoute()` |
| lucide-react `import { Wifi } from "lucide-react"` | `import { Wifi } from "lucide-vue-next"` (same icon names) |
| `<Icon className="h-5 w-5" />` | `<Icon class="h-5 w-5" />` |
| framer-motion `<motion.div initial=... animate=... />` | `<div v-motion :initial="..." :enter="...">` (from `@vueuse/motion`); `whileInView``:visible-once="..."` |
| `AnimatePresence` | usually a `<Transition>` or just `v-if` — keep it simple, match the visual |
| Toasts: `useToast()` / `toast(...)` (shadcn) | `import { useToast } from "@/components/ui/toast/use-toast"` (same API) |
| `sonner` `toast()` | `import { toast } from "vue-sonner"` |
| react-hook-form + zod | `vee-validate` + `zod` via `@/components/ui/form`, OR for simple cases just `ref`s + manual zod `.safeParse` |
## shadcn-vue components
Import from the folder index, e.g. `import { Button } from "@/components/ui/button"`,
`import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"`.
Component/prop names and the `variant`/`size` props match shadcn/ui. Slots replace React children.
`asChild` exists in reka-ui too. Available under `src/components/ui/`: accordion, alert, alert-dialog,
aspect-ratio, avatar, badge, breadcrumb, button, calendar, card, carousel, checkbox, collapsible, command,
context-menu, dialog, drawer, dropdown-menu, form, hover-card, input, label, menubar, navigation-menu,
pagination, popover, progress, radio-group, resizable, scroll-area, select, separator, sheet, skeleton,
slider, sonner, switch, table, tabs, textarea, toast, toggle, toggle-group, tooltip.
## Structure of a ported page
```vue
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { RouterLink } from "vue-router";
import { Button } from "@/components/ui/button";
import { Wifi } from "lucide-vue-next";
import Header from "@/components/layout/Header.vue";
import Footer from "@/components/layout/Footer.vue";
// ...page-local data/state
</script>
<template>
<div class="min-h-screen bg-background">
<Header />
<!-- exact JSX markup, className→class -->
<Footer />
</div>
</template>
```
## Do / Don't
- DO keep every Tailwind class string identical to the React source.
- DO keep the same DOM structure, headings, copy (French text) and image URLs.
- DO reuse `Header`, `Footer`, `BusinessPageLayout`, `GlassCard`, `Logo` from `@/components/...`.
- DON'T add new colors, spacing, or restyle. DON'T invent content.
- DON'T pull in new deps; everything needed is installed.
- If a React file uses `input-otp` (not in the shadcn-vue registry), use a plain grouped `Input` set or note it.
- If something can't be perfectly matched, leave a `<!-- TODO(port): ... -->` and keep the closest visual.
## Assets
Static files live in `public/` and resolve under `/next/...` (Vite `base: '/next/'`). Copy any images the
React app references from `../website/public` into `./public` (same relative paths).

View File

@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "default",
"typescript": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

7
apps/website-vue/env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

View File

@ -0,0 +1,24 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Aperçu /next : ne PAS indexer tant que la refonte n'a pas remplacé le site à la racine (évite le duplicate-content SEO). -->
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="/next/favicon.ico" type="image/x-icon" />
<title>Targo</title>
<meta name="description" content="TARGO Internet fibre optique, télévision HD et téléphonie résidentielle. 100% fibre locale au Québec." />
<meta name="author" content="TARGO Communications" />
<meta property="og:title" content="TARGO Internet fibre optique locale" />
<meta property="og:description" content="Internet fibre optique, télévision HD et téléphonie résidentielle. 100% fibre locale au Québec." />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

4138
apps/website-vue/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
{
"name": "gigafibre-website-vue",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@lucide/vue": "^1.23.0",
"@supabase/supabase-js": "^2.110.0",
"@tanstack/vue-query": "^5.101.2",
"@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^12.8.2",
"@vueuse/motion": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-vue": "^8.6.0",
"fflate": "^0.8.3",
"html2pdf.js": "^0.10.3",
"lucide-vue-next": "^0.462.0",
"reka-ui": "^2.10.1",
"tailwind-merge": "^2.6.0",
"vaul-vue": "^0.4.1",
"vee-validate": "^4.15.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"vue-sonner": "^1.3.2",
"zod": "^3.25.76"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.2",
"vite": "^5.4.11",
"vue-tsc": "^2.1.10"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
// Miroir de App.tsx : TooltipProvider global + toasts (shadcn + sonner) + RouterView.
// ScrollToTop est géré par le scrollBehavior du router.
import { TooltipProvider } from "reka-ui";
import { Toaster } from "@/components/ui/toast";
import { Toaster as Sonner } from "@/components/ui/sonner";
</script>
<template>
<TooltipProvider>
<Toaster />
<Sonner />
<RouterView />
</TooltipProvider>
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -0,0 +1,114 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 280" fill="none">
<!-- Couch -->
<rect x="30" y="175" width="300" height="55" rx="10" fill="#d4c4a8"/>
<rect x="40" y="158" width="280" height="25" rx="6" fill="#e8dcc8"/>
<ellipse cx="40" cy="190" rx="15" ry="28" fill="#d4c4a8"/>
<ellipse cx="320" cy="190" rx="15" ry="28" fill="#d4c4a8"/>
<rect x="55" y="230" width="6" height="12" rx="2" fill="#242a28"/>
<rect x="299" y="230" width="6" height="12" rx="2" fill="#242a28"/>
<!-- Dad (right side) -->
<g transform="translate(230, 75)">
<!-- Body -->
<rect x="12" y="50" width="36" height="48" rx="6" fill="#242a28"/>
<!-- Head -->
<circle cx="30" cy="28" r="22" fill="#f5d0c5"/>
<!-- Hair -->
<path d="M10 22 Q10 6 30 6 Q50 6 50 22" fill="#242a28"/>
<!-- Eyes -->
<circle cx="22" cy="26" r="2.5" fill="#242a28"/>
<circle cx="38" cy="26" r="2.5" fill="#242a28"/>
<!-- Smile -->
<path d="M23 38 Q30 44 37 38" stroke="#242a28" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- Arms -->
<rect x="2" y="55" width="12" height="6" rx="3" fill="#242a28"/>
<rect x="46" y="55" width="12" height="6" rx="3" fill="#242a28"/>
<!-- Hands -->
<circle cx="0" cy="58" r="6" fill="#f5d0c5"/>
<circle cx="60" cy="58" r="6" fill="#f5d0c5"/>
<!-- Legs -->
<rect x="16" y="98" width="10" height="32" rx="4" fill="#242a28"/>
<rect x="34" y="98" width="10" height="32" rx="4" fill="#242a28"/>
</g>
<!-- Mom (left side) -->
<g transform="translate(70, 80)">
<!-- Body -->
<rect x="12" y="48" width="36" height="45" rx="6" fill="#ffffff" stroke="#e0e0e0"/>
<!-- Head -->
<circle cx="30" cy="26" r="21" fill="#f5d0c5"/>
<!-- Hair -->
<path d="M10 30 Q6 12 30 6 Q54 12 50 30 Q48 18 30 16 Q12 18 10 30" fill="#3d2314"/>
<path d="M10 30 Q8 48 15 58" stroke="#3d2314" stroke-width="5" fill="none" stroke-linecap="round"/>
<path d="M50 30 Q52 48 45 58" stroke="#3d2314" stroke-width="5" fill="none" stroke-linecap="round"/>
<!-- Eyes -->
<circle cx="22" cy="24" r="2" fill="#242a28"/>
<circle cx="38" cy="24" r="2" fill="#242a28"/>
<!-- Smile -->
<path d="M23 35 Q30 40 37 35" stroke="#242a28" stroke-width="2" fill="none" stroke-linecap="round"/>
<!-- Arms -->
<rect x="46" y="52" width="12" height="5" rx="2.5" fill="#f5d0c5"/>
<rect x="2" y="52" width="12" height="5" rx="2.5" fill="#f5d0c5"/>
<!-- Hands -->
<circle cx="60" cy="54" r="5" fill="#f5d0c5"/>
<circle cx="0" cy="54" r="5" fill="#f5d0c5"/>
<!-- Legs -->
<rect x="16" y="93" width="10" height="28" rx="4" fill="#242a28"/>
<rect x="34" y="93" width="10" height="28" rx="4" fill="#242a28"/>
</g>
<!-- Child 1 (boy, center-left) -->
<g transform="translate(135, 105)">
<!-- Body -->
<rect x="8" y="36" width="28" height="32" rx="5" fill="#22c55e"/>
<!-- Head -->
<circle cx="22" cy="18" r="16" fill="#f5d0c5"/>
<!-- Hair -->
<path d="M8 14 Q8 4 22 4 Q36 4 36 14" fill="#242a28"/>
<!-- Eyes -->
<circle cx="16" cy="16" r="2" fill="#242a28"/>
<circle cx="28" cy="16" r="2" fill="#242a28"/>
<!-- Smile -->
<path d="M17 26 Q22 30 27 26" stroke="#242a28" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<!-- Arms -->
<rect x="34" y="40" width="10" height="4" rx="2" fill="#22c55e"/>
<rect x="0" y="40" width="10" height="4" rx="2" fill="#22c55e"/>
<!-- Hands -->
<circle cx="46" cy="42" r="4" fill="#f5d0c5"/>
<circle cx="-2" cy="42" r="4" fill="#f5d0c5"/>
<!-- Legs -->
<rect x="11" y="68" width="8" height="22" rx="3" fill="#242a28"/>
<rect x="25" y="68" width="8" height="22" rx="3" fill="#242a28"/>
</g>
<!-- Child 2 (girl, center-right) -->
<g transform="translate(180, 108)">
<!-- Body/Dress -->
<rect x="8" y="34" width="28" height="30" rx="5" fill="#ffffff" stroke="#e0e0e0"/>
<!-- Head -->
<circle cx="22" cy="16" r="15" fill="#f5d0c5"/>
<!-- Hair -->
<path d="M8 14 Q8 2 22 2 Q36 2 36 14" fill="#3d2314"/>
<path d="M8 14 Q6 24 10 30" stroke="#3d2314" stroke-width="4" fill="none" stroke-linecap="round"/>
<path d="M36 14 Q38 24 34 30" stroke="#3d2314" stroke-width="4" fill="none" stroke-linecap="round"/>
<!-- Eyes -->
<circle cx="16" cy="14" r="2" fill="#242a28"/>
<circle cx="28" cy="14" r="2" fill="#242a28"/>
<!-- Smile -->
<path d="M17 23 Q22 27 27 23" stroke="#242a28" stroke-width="1.5" fill="none" stroke-linecap="round"/>
<!-- Arms -->
<rect x="34" y="38" width="10" height="4" rx="2" fill="#f5d0c5"/>
<rect x="0" y="38" width="10" height="4" rx="2" fill="#f5d0c5"/>
<!-- Hands -->
<circle cx="46" cy="40" r="4" fill="#f5d0c5"/>
<circle cx="-2" cy="40" r="4" fill="#f5d0c5"/>
<!-- Legs -->
<rect x="11" y="64" width="8" height="20" rx="3" fill="#242a28"/>
<rect x="25" y="64" width="8" height="20" rx="3" fill="#242a28"/>
</g>
<!-- Decorative dots -->
<circle cx="50" cy="55" r="4" fill="#22c55e" opacity="0.5"/>
<circle cx="310" cy="45" r="5" fill="#22c55e" opacity="0.4"/>
<circle cx="180" cy="35" r="3" fill="#22c55e" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35"><defs><style>.cls-1{fill:#019547;}</style></defs><path class="cls-1" d="M25.83,15H8.41a4.14,4.14,0,0,1-3.89-2.71L0,1.18H66.59L62.07,12.27A4.14,4.14,0,0,1,58.18,15H40.76V69.19H25.81Z"/><path class="cls-1" d="M90.74.68h18.55a5.63,5.63,0,0,1,5.63,5.6l.26,62.91H99.55L99.76,54H71.87L66.41,65.51a6.45,6.45,0,0,1-5.84,3.68H49.35L73.53,12.11A18.7,18.7,0,0,1,90.74.68M100,40.73l.07-26h-8a6.75,6.75,0,0,0-6.26,4.18L76.73,40.73Z"/><path class="cls-1" d="M124.51,6.81a5.64,5.64,0,0,1,5.65-5.65H155.6c8.64,0,15.37,2.44,19.81,6.91,3.81,3.78,5.84,9.14,5.84,15.53v.18c0,11-5.92,17.87-14.59,21.1l16.61,24.29h-14a6.51,6.51,0,0,1-5.39-2.87L151.21,47.41H139.44V69.17h-15Zm30.12,27.41c7.28,0,11.45-3.89,11.45-9.62v-.21c0-6.42-4.46-9.7-11.74-9.7H139.46V34.22Z"/><path class="cls-1" d="M184.74,35.37v-.18C184.74,15.85,199.8,0,220.4,0,232.65,0,240,3.31,247.13,9.33l-6.28,7.57a5.18,5.18,0,0,1-6.84,1,23.71,23.71,0,0,0-14.11-4.13c-10.88,0-19.52,9.62-19.52,21.18v.19c0,12.43,8.54,21.57,20.6,21.57a24,24,0,0,0,14.09-4.07V42.91h-8.68A6.41,6.41,0,0,1,220,36.53V30h29.54V59.52a44,44,0,0,1-29,10.78c-21.18,0-35.74-14.8-35.74-34.93"/><path class="cls-1" d="M254.09,35.37v-.18C254.09,15.85,269.33,0,290.33,0s36.06,15.66,36.06,35v.18c0,19.34-15.27,35.19-36.24,35.19s-36.06-15.64-36.06-35m56.63,0v-.18c0-11.67-8.54-21.39-20.6-21.39S269.73,23.34,269.73,35v.18c0,11.67,8.54,21.37,20.6,21.37S310.72,47,310.72,35.37"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35"><defs><style>.cls-1{fill:#ffffff;}</style></defs><path class="cls-1" d="M25.83,15H8.41a4.14,4.14,0,0,1-3.89-2.71L0,1.18H66.59L62.07,12.27A4.14,4.14,0,0,1,58.18,15H40.76V69.19H25.81Z"/><path class="cls-1" d="M90.74.68h18.55a5.63,5.63,0,0,1,5.63,5.6l.26,62.91H99.55L99.76,54H71.87L66.41,65.51a6.45,6.45,0,0,1-5.84,3.68H49.35L73.53,12.11A18.7,18.7,0,0,1,90.74.68M100,40.73l.07-26h-8a6.75,6.75,0,0,0-6.26,4.18L76.73,40.73Z"/><path class="cls-1" d="M124.51,6.81a5.64,5.64,0,0,1,5.65-5.65H155.6c8.64,0,15.37,2.44,19.81,6.91,3.81,3.78,5.84,9.14,5.84,15.53v.18c0,11-5.92,17.87-14.59,21.1l16.61,24.29h-14a6.51,6.51,0,0,1-5.39-2.87L151.21,47.41H139.44V69.17h-15Zm30.12,27.41c7.28,0,11.45-3.89,11.45-9.62v-.21c0-6.42-4.46-9.7-11.74-9.7H139.46V34.22Z"/><path class="cls-1" d="M184.74,35.37v-.18C184.74,15.85,199.8,0,220.4,0,232.65,0,240,3.31,247.13,9.33l-6.28,7.57a5.18,5.18,0,0,1-6.84,1,23.71,23.71,0,0,0-14.11-4.13c-10.88,0-19.52,9.62-19.52,21.18v.19c0,12.43,8.54,21.57,20.6,21.57a24,24,0,0,0,14.09-4.07V42.91h-8.68A6.41,6.41,0,0,1,220,36.53V30h29.54V59.52a44,44,0,0,1-29,10.78c-21.18,0-35.74-14.8-35.74-34.93"/><path class="cls-1" d="M254.09,35.37v-.18C254.09,15.85,269.33,0,290.33,0s36.06,15.66,36.06,35v.18c0,19.34-15.27,35.19-36.24,35.19s-36.06-15.64-36.06-35m56.63,0v-.18c0-11.67-8.54-21.39-20.6-21.39S269.73,23.34,269.73,35v.18c0,11.67,8.54,21.37,20.6,21.37S310.72,47,310.72,35.37"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@ -0,0 +1,413 @@
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from "vue";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Search,
MapPin,
CheckCircle2,
XCircle,
Loader2,
ArrowLeft,
Zap,
Mail,
Phone,
Send,
} from "lucide-vue-next";
import { z } from "zod";
// Address search via our own API (no Supabase dependency)
const API_BASE = import.meta.env.VITE_API_BASE || "";
const phoneClean = (v: string) => v.replace(/[\s\-().]/g, "");
const contactSchema = z.union([
z.string().trim().email().max(254),
z.string().trim().transform(phoneClean).pipe(
z.string().regex(/^\+?1?\d{10,11}$/, "Numéro invalide"),
),
]);
const CONTACT_COOLDOWN_MS = 5000;
interface AddressResult {
id: number;
address_full: string;
numero: string;
rue: string;
ville: string;
code_postal: string;
longitude: number;
latitude: number;
fiber_available: boolean;
zone_tarifaire: number;
max_speed: number;
similarity_score: number;
}
type Step = "search" | "result";
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ "update:open": [value: boolean] }>();
const openModel = computed({
get: () => props.open,
set: (v: boolean) => emit("update:open", v),
});
const query = ref("");
const results = ref<AddressResult[]>([]);
const searching = ref(false);
const selected = ref<AddressResult | null>(null);
const step = ref<Step>("search");
const checking = ref(false);
const contactValue = ref("");
const contactSent = ref(false);
const sendingContact = ref(false);
const contactError = ref("");
let lastSubmit = 0;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
// Reset on close
watch(
() => props.open,
(open) => {
if (!open) {
setTimeout(() => {
query.value = "";
results.value = [];
selected.value = null;
step.value = "search";
contactValue.value = "";
contactSent.value = false;
}, 300);
}
},
);
// Cleanup debounce on unmount
onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer);
});
const searchAddresses = async (term: string) => {
if (term.length < 3) {
results.value = [];
return;
}
searching.value = true;
try {
const res = await fetch(API_BASE + "/rpc/search_addresses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ search_term: term, result_limit: 8 }),
});
if (res.ok) {
const data = await res.json();
results.value = data as AddressResult[];
}
} finally {
searching.value = false;
}
};
const handleQueryChange = (value: string) => {
query.value = value;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => searchAddresses(value), 350);
};
const handleValidate = async (address: AddressResult) => {
selected.value = address;
checking.value = true;
step.value = "result";
// Log the search (fire and forget)
try {
console.log("[Search]", query.value, "→", address.address_full, address.fiber_available);
} catch (e) {
console.error("Log search error:", e);
}
// Small delay for UX
await new Promise((r) => setTimeout(r, 800));
checking.value = false;
};
const handleContactSubmit = async () => {
if (!contactValue.value.trim() || !selected.value) return;
// Rate limit
const now = Date.now();
if (now - lastSubmit < CONTACT_COOLDOWN_MS) {
contactError.value = "Veuillez patienter quelques secondes.";
return;
}
// Validate contact
const result = contactSchema.safeParse(contactValue.value.trim());
if (!result.success) {
contactError.value = "Veuillez entrer un courriel ou numéro de mobile valide.";
return;
}
contactError.value = "";
sendingContact.value = true;
lastSubmit = now;
const validContact = result.data;
// Send lead notification via Mailjet
try {
await fetch(API_BASE + "/rpc/lead", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contact: validContact,
address: selected.value.address_full,
fiber_available: selected.value.fiber_available,
max_speed: selected.value.max_speed || 0,
}),
});
} catch (e) {
console.error("Lead send error:", e);
}
sendingContact.value = false;
contactSent.value = true;
};
const speedLabel = (speed: number) => {
if (!speed || speed === 0) return "1.5 Gbit/s";
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbit/s`;
return `${speed} Mbit/s`;
};
const backToSearch = () => {
step.value = "search";
selected.value = null;
contactSent.value = false;
contactValue.value = "";
};
</script>
<template>
<Dialog v-model:open="openModel">
<DialogContent class="sm:max-w-lg max-h-[85vh] overflow-hidden flex flex-col top-[8%] translate-y-0 sm:top-[50%] sm:translate-y-[-50%]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2 text-xl">
<Zap class="w-5 h-5 text-targo-green" />
Vérifier la disponibilité
</DialogTitle>
<DialogDescription>
Entrez votre adresse pour vérifier si la fibre optique est disponible
</DialogDescription>
</DialogHeader>
<Transition mode="out-in">
<div
v-if="step === 'search'"
key="search"
v-motion
:initial="{ opacity: 0, x: -20 }"
:enter="{ opacity: 1, x: 0 }"
:leave="{ opacity: 0, x: -20 }"
class="space-y-4 flex-1 overflow-hidden flex flex-col"
>
<!-- Search input -->
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Ex: 1933 Avenue Goulet, Montréal"
:model-value="query"
class="pl-10"
autofocus
@update:model-value="(v) => handleQueryChange(String(v))"
/>
<Loader2 v-if="searching" class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" />
</div>
<!-- Results list -->
<div class="flex-1 overflow-y-auto space-y-1 max-h-[50vh]">
<p v-if="results.length === 0 && query.length >= 3 && !searching" class="text-sm text-muted-foreground text-center py-8">
Aucune adresse trouvée
</p>
<button
v-for="addr in results"
:key="addr.id"
class="w-full text-left p-3 rounded-lg border border-border hover:border-targo-green hover:bg-targo-green/5 transition-all duration-200 group"
@click="handleValidate(addr)"
>
<div class="flex items-start gap-3">
<MapPin class="w-4 h-4 mt-0.5 text-muted-foreground group-hover:text-targo-green shrink-0" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">
{{ addr.address_full }}
</p>
<p class="text-xs text-muted-foreground">
{{ addr.ville }} {{ addr.code_postal }}
</p>
</div>
<span v-if="addr.fiber_available" class="ml-auto shrink-0 text-[10px] font-bold uppercase tracking-wider text-targo-green bg-targo-green/10 px-2 py-0.5 rounded-full">
Fibre
</span>
</div>
</button>
</div>
</div>
<div
v-else-if="step === 'result' && selected"
key="result"
v-motion
:initial="{ opacity: 0, x: 20 }"
:enter="{ opacity: 1, x: 0 }"
:leave="{ opacity: 0, x: 20 }"
class="space-y-6"
>
<div v-if="checking" class="flex flex-col items-center py-12 gap-4">
<Loader2 class="w-10 h-10 animate-spin text-targo-green" />
<p class="text-sm text-muted-foreground">
Vérification en cours
</p>
</div>
<!-- AVAILABLE -->
<div v-else-if="selected.fiber_available" class="space-y-6">
<div class="text-center space-y-3">
<div class="w-16 h-16 rounded-full bg-targo-green/10 flex items-center justify-center mx-auto">
<CheckCircle2 class="w-8 h-8 text-targo-green" />
</div>
<h3 class="text-xl font-bold text-foreground">
Félicitations !
</h3>
<p class="text-muted-foreground text-sm">
Le service est disponible à votre adresse jusqu'à{{ " " }}
<span class="font-bold text-targo-green">
{{ speedLabel(selected.max_speed) }}
</span>
</p>
<div class="bg-primary/5 border border-primary/20 rounded-xl p-4 mt-2">
<p class="text-xs text-muted-foreground mb-1">Votre forfait</p>
<p class="text-2xl font-bold text-primary font-display">
À partir de 39<sup class="text-sm">,95</sup>$/mois
</p>
</div>
<p class="text-xs text-muted-foreground bg-muted/50 rounded-lg p-3">
<MapPin class="inline w-3 h-3 mr-1" />
{{ selected.address_full }}
</p>
</div>
<div v-if="!contactSent" class="space-y-3">
<p class="text-sm font-medium text-center">
Inscrivez votre courriel ou numéro de mobile pour
démarrer votre abonnement
</p>
<div class="space-y-1">
<div class="flex gap-2">
<Input
placeholder="courriel ou mobile"
:model-value="contactValue"
class="flex-1"
:maxlength="254"
@update:model-value="(v) => { contactValue = String(v); contactError = ''; }"
/>
<Button
:disabled="!contactValue.trim() || sendingContact"
class="gradient-targo"
@click="handleContactSubmit"
>
<Loader2 v-if="sendingContact" class="w-4 h-4 animate-spin" />
<Send v-else class="w-4 h-4" />
</Button>
</div>
<p v-if="contactError" class="text-xs text-destructive">{{ contactError }}</p>
</div>
</div>
<div v-else class="text-center text-sm text-targo-green font-medium bg-targo-green/10 rounded-lg p-4">
<CheckCircle2 class="w-5 h-5 mx-auto mb-2" />
Merci ! Nous vous contacterons très bientôt.
</div>
</div>
<!-- NOT AVAILABLE -->
<div v-else class="space-y-6">
<div class="text-center space-y-3">
<div class="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mx-auto">
<XCircle class="w-8 h-8 text-destructive" />
</div>
<h3 class="text-xl font-bold text-foreground">
Adresse non confirmée
</h3>
<p class="text-muted-foreground text-sm">
Nous n'arrivons pas à confirmer la disponibilité pour
votre adresse.
</p>
<p class="text-xs text-muted-foreground bg-muted/50 rounded-lg p-3">
<MapPin class="inline w-3 h-3 mr-1" />
{{ selected.address_full }}
</p>
</div>
<div class="space-y-4">
<div class="flex items-center justify-center gap-4 text-sm">
<a
href="tel:18558882746"
class="flex items-center gap-2 text-targo-green hover:underline font-medium"
>
<Phone class="w-4 h-4" />
1-855-888-2746
</a>
</div>
<div class="text-center text-xs text-muted-foreground">
ou inscrivez votre courriel et nous vous contacterons
</div>
<div v-if="!contactSent" class="space-y-1">
<div class="flex gap-2">
<Input
placeholder="votre courriel"
:model-value="contactValue"
class="flex-1"
:maxlength="254"
@update:model-value="(v) => { contactValue = String(v); contactError = ''; }"
/>
<Button
:disabled="!contactValue.trim() || sendingContact"
variant="outline"
class="border-targo-green text-targo-green hover:bg-targo-green/5"
@click="handleContactSubmit"
>
<Loader2 v-if="sendingContact" class="w-4 h-4 animate-spin" />
<Mail v-else class="w-4 h-4" />
</Button>
</div>
<p v-if="contactError" class="text-xs text-destructive">{{ contactError }}</p>
</div>
<div v-else class="text-center text-sm text-targo-green font-medium bg-targo-green/10 rounded-lg p-4">
<CheckCircle2 class="w-5 h-5 mx-auto mb-2" />
Merci ! Nous vous contacterons très bientôt.
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
class="w-full"
@click="backToSearch"
>
<ArrowLeft class="w-4 h-4 mr-2" />
Rechercher une autre adresse
</Button>
</div>
</Transition>
</DialogContent>
</Dialog>
</template>

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
// Vue port of React ProtectedAdminRoute.
// Usage: wrap an admin page's content, e.g. in AdminImport.vue:
// <ProtectedAdminRoute><AdminImportContent /></ProtectedAdminRoute>
// It redirects to /admin/login (router.replace) when the user is not an admin.
// (See report for the alternative router navigation-guard wiring.)
import { ref, watch, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { supabase } from "@/integrations/supabase/client";
import { Loader2 } from "lucide-vue-next";
const router = useRouter();
const loading = ref(true);
const authorized = ref(false);
let initialCheckDone = false;
let unsubscribe: (() => void) | undefined;
const checkAdmin = async (userId: string) => {
const { data: roleRow } = await supabase
.from("user_roles")
.select("role")
.eq("user_id", userId)
.eq("role", "admin")
.maybeSingle();
authorized.value = !!roleRow;
loading.value = false;
};
onMounted(() => {
// Listen to auth state changes but ignore null session until initial check is done
const { data } = supabase.auth.onAuthStateChange((_event, session) => {
if (session?.user) {
initialCheckDone = true;
checkAdmin(session.user.id);
} else if (initialCheckDone) {
// Only redirect if we already confirmed the initial state
authorized.value = false;
loading.value = false;
}
});
unsubscribe = () => data.subscription.unsubscribe();
// Initial session check this is the source of truth
supabase.auth.getSession().then(({ data: { session } }) => {
initialCheckDone = true;
if (session?.user) {
checkAdmin(session.user.id);
} else {
authorized.value = false;
loading.value = false;
}
});
});
onUnmounted(() => {
unsubscribe?.();
});
// Redirect once we know the user is not authorized (equivalent of <Navigate to="/admin/login" replace />)
watch([loading, authorized], ([l, a]) => {
if (!l && !a) {
router.replace("/admin/login");
}
});
</script>
<template>
<div v-if="loading" class="min-h-screen flex items-center justify-center">
<Loader2 class="w-8 h-8 animate-spin text-muted-foreground" />
</div>
<slot v-else-if="authorized" />
</template>

View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed } from "vue";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { searchItems, type SearchItem } from "@/lib/searchData";
import { useRouter } from "vue-router";
import { Search, Monitor, Briefcase, HelpCircle, FileText, Globe } from "lucide-vue-next";
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ "update:open": [value: boolean] }>();
const router = useRouter();
const runCommand = (command: () => unknown) => {
emit("update:open", false);
command();
};
const getIcon = (type: string) => {
switch (type) {
case "Page":
return Monitor;
case "Business":
return Briefcase;
case "Support":
return HelpCircle;
case "Legal":
return FileText;
default:
return Search;
}
};
const onSelect = (item: SearchItem) => {
if (item.url.startsWith("http")) {
runCommand(() => window.open(item.url, "_blank"));
} else {
runCommand(() => router.push(item.url));
}
};
const pages = searchItems.filter((item) => item.type === "Page");
const business = searchItems.filter((item) => item.type === "Business");
const support = searchItems.filter((item) => item.type === "Support");
const openModel = computed({
get: () => props.open,
set: (v: boolean) => emit("update:open", v),
});
</script>
<template>
<CommandDialog v-model:open="openModel">
<CommandInput placeholder="Rechercher une page ou un service..." />
<CommandList>
<CommandEmpty>Aucun résultat trouvé.</CommandEmpty>
<CommandGroup v-if="pages.length > 0" heading="Pages Principales">
<CommandItem
v-for="item in pages"
:key="item.id"
:value="`${item.title} ${item.keywords?.join(' ')}`"
@select="onSelect(item)"
>
<component :is="getIcon(item.type)" class="mr-2 h-4 w-4" />
<span>{{ item.title }}</span>
<Globe v-if="item.id === 'client-portal'" class="ml-auto h-4 w-4 text-muted-foreground" />
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup v-if="business.length > 0" heading="Solutions Affaires">
<CommandItem
v-for="item in business"
:key="item.id"
:value="`${item.title} ${item.keywords?.join(' ')}`"
@select="onSelect(item)"
>
<component :is="getIcon(item.type)" class="mr-2 h-4 w-4" />
<span>{{ item.title }}</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup v-if="support.length > 0" heading="Support & Aide">
<CommandItem
v-for="item in support"
:key="item.id"
:value="`${item.title} ${item.keywords?.join(' ')}`"
@select="onSelect(item)"
>
<component :is="getIcon(item.type)" class="mr-2 h-4 w-4" />
<span>{{ item.title }}</span>
<Globe v-if="item.id === 'client-portal'" class="ml-auto h-4 w-4 text-muted-foreground" />
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</template>

View File

@ -0,0 +1,178 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
const props = withDefaults(
defineProps<{ speed: string; size?: number }>(),
{ size: 140 },
);
const animatedValue = ref(0);
// Parse speed to get a normalized value (0-1) for indicator position
// Uses logarithmic curve: fast climb for lower speeds, diminishing impact for higher speeds
const getSpeedValue = (speedStr: string): number => {
const lower = speedStr.toLowerCase();
let mbps: number;
if (lower.includes("gbps")) {
mbps = parseFloat(speedStr) * 1000; // Convert Gbps to Mbps
} else {
mbps = parseFloat(speedStr);
}
// Logarithmic curve: log(1 + x * k) / log(1 + k)
// Higher k = faster climb for lower speeds, compressed difference at higher speeds
const maxSpeed = 3500;
const k = 50; // Higher value for more aggressive lower speed progression
const normalized = Math.min(mbps / maxSpeed, 1);
return Math.log(1 + normalized * k) / Math.log(1 + k);
};
const targetValue = computed(() => getSpeedValue(props.speed));
// Animate on mount
onMounted(() => {
const duration = 1200;
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// Ease-out cubic for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3);
animatedValue.value = eased * targetValue.value;
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
});
// Parse speed into number and unit
const speedNumber = computed(() => parseFloat(props.speed));
const speedUnit = computed(() =>
props.speed.toLowerCase().includes("gbps") ? "Gbps" : "Mbps",
);
const strokeWidth = 14;
const radius = computed(() => (props.size - strokeWidth) / 2);
const center = computed(() => props.size / 2);
// Arc path for the gauge
const polarToCartesian = (cx: number, cy: number, r: number, angleDeg: number) => {
const rad = ((angleDeg - 90) * Math.PI) / 180;
return {
x: cx + r * Math.cos(rad),
y: cy + r * Math.sin(rad),
};
};
const describeArc = (cx: number, cy: number, r: number, startAngle: number, endAngle: number) => {
const start = polarToCartesian(cx, cy, r, endAngle);
const end = polarToCartesian(cx, cy, r, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} 0 ${end.x} ${end.y}`;
};
// Full arc from -135 to 135 (270 degrees)
const arcPath = computed(() => describeArc(center.value, center.value, radius.value, -135, 135));
// Filled arc based on animated value
const filledAngle = computed(() => -135 + animatedValue.value * 270);
const filledArcPath = computed(() =>
describeArc(center.value, center.value, radius.value, -135, filledAngle.value),
);
// Position for the indicator circle
const indicatorPos = computed(() =>
polarToCartesian(center.value, center.value, radius.value, filledAngle.value),
);
const uniqueId = computed(() => `speedGradient-${props.speed.replace(/\s/g, "-")}`);
// Gradient with lighter green tones
const colors = { start: "#34d399", mid: "#4ade80", end: "#86efac" };
</script>
<template>
<div class="relative flex flex-col items-center" :style="{ width: `${size}px`, height: `${size * 0.7}px` }">
<svg
:viewBox="`0 0 ${size} ${size}`"
:width="size"
:height="size * 0.7"
class="overflow-visible"
>
<defs>
<linearGradient :id="uniqueId" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" :stop-color="colors.start" />
<stop offset="50%" :stop-color="colors.mid" />
<stop offset="100%" :stop-color="colors.end" />
</linearGradient>
<filter :id="`shadow-${uniqueId}`" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.35" />
</filter>
</defs>
<!-- Background arc (grey) -->
<path
:d="arcPath"
fill="none"
stroke="hsl(var(--muted))"
:stroke-width="strokeWidth"
stroke-linecap="round"
/>
<!-- Colored gradient arc -->
<path
:d="filledArcPath"
fill="none"
:stroke="`url(#${uniqueId})`"
:stroke-width="strokeWidth / 2"
stroke-linecap="round"
/>
<!-- Indicator circle with shadow -->
<g :filter="`url(#shadow-${uniqueId})`">
<circle
:cx="indicatorPos.x"
:cy="indicatorPos.y"
:r="10"
fill="hsl(var(--muted))"
/>
<circle
:cx="indicatorPos.x"
:cy="indicatorPos.y"
:r="8"
fill="white"
/>
</g>
<!-- Speed number - large -->
<text
:x="center"
:y="center"
text-anchor="middle"
dominant-baseline="middle"
class="fill-primary font-bold"
:style="{ fontSize: `${size * 0.35}px`, letterSpacing: '-0.05em' }"
>
{{ speedNumber }}
</text>
<!-- Speed unit - small -->
<text
:x="center"
:y="center + size * 0.20"
text-anchor="middle"
class="fill-muted-foreground font-medium"
:style="{ fontSize: `${size * 0.12}px` }"
>
{{ speedUnit }}
</text>
</svg>
</div>
</template>

View File

@ -0,0 +1,85 @@
<script setup lang="ts">
import type { Component } from "vue";
import { RouterLink } from "vue-router";
import Header from "@/components/layout/Header.vue";
import Footer from "@/components/layout/Footer.vue";
import { Button } from "@/components/ui/button";
import { ArrowRight, Phone } from "lucide-vue-next";
defineProps<{
title: string;
subtitle?: string;
description: string;
icon?: Component;
heroImage?: string;
}>();
</script>
<template>
<div class="min-h-screen bg-targo-dark text-white selection:bg-targo-green selection:text-white">
<Header />
<!-- Hero Section -->
<section class="relative pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden">
<!-- Background Elements -->
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_var(--tw-gradient-stops))] from-targo-green/20 via-targo-dark to-targo-dark" />
<div class="absolute top-0 left-0 w-full h-full bg-[url('/grid-pattern.svg')] opacity-5" />
<div class="container relative mx-auto px-4">
<div
v-motion
:initial="{ opacity: 0, y: 30 }"
:enter="{ opacity: 1, y: 0, transition: { duration: 800, ease: 'easeOut' } }"
class="max-w-4xl mx-auto text-center"
>
<RouterLink
to="/business"
class="inline-flex items-center text-targo-green-light hover:text-white transition-colors mb-8 text-sm font-medium tracking-wide uppercase"
>
Retour aux solutions affaires
</RouterLink>
<div
v-if="icon"
v-motion
:initial="{ scale: 0.8, opacity: 0 }"
:enter="{ scale: 1, opacity: 1, transition: { delay: 200, duration: 500 } }"
class="w-20 h-20 bg-targo-green/10 rounded-2xl flex items-center justify-center mx-auto mb-8 border border-targo-green/20 backdrop-blur-sm shadow-[0_0_30px_rgba(0,200,83,0.2)]"
>
<component :is="icon" class="h-10 w-10 text-targo-green" />
</div>
<h1 class="text-5xl md:text-6xl lg:text-7xl font-bold mb-6 font-display tracking-tight">
{{ title }} <span class="text-transparent bg-clip-text bg-gradient-to-r from-targo-green to-targo-green-light">{{ subtitle }}</span>
</h1>
<p class="text-lg md:text-xl text-gray-400 mb-10 max-w-3xl mx-auto leading-relaxed">
{{ description }}
</p>
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{ opacity: 1, y: 0, transition: { delay: 400, duration: 500 } }"
class="flex flex-col sm:flex-row gap-4 justify-center"
>
<Button size="lg" class="bg-targo-green hover:bg-targo-green-dark text-white font-bold text-lg px-8 py-6 rounded-xl shadow-lg shadow-targo-green/20 transition-all hover:scale-105">
Demander une soumission
<ArrowRight class="ml-2 h-5 w-5" />
</Button>
<Button size="lg" variant="outline" class="text-lg px-8 py-6 border-white/10 bg-white/5 text-white hover:bg-white/10 hover:text-white rounded-xl backdrop-blur-sm transition-all hover:scale-105">
<Phone class="mr-2 h-5 w-5" />
1-855-888-2746
</Button>
</div>
</div>
</div>
</section>
<slot />
<div class="border-t border-white/5">
<Footer />
</div>
</div>
</template>

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import { Phone, Mail, MapPin } from "lucide-vue-next";
import targoLogo from "@/assets/targo-logo.png";
const currentYear = new Date().getFullYear();
</script>
<template>
<footer class="bg-targo-dark text-white pt-24 pb-12 border-t border-white/5 relative overflow-hidden">
<!-- Decorative background element -->
<div class="absolute top-0 right-0 w-[500px] h-[500px] bg-targo-green/5 rounded-full blur-[120px] -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<div class="container">
<div class="grid md:grid-cols-4 gap-12 mb-12">
<!-- Brand -->
<div class="md:col-span-1 space-y-6">
<img :src="targoLogo" alt="Targo - 100% Fibre" class="h-10 mb-2 brightness-0 invert" />
<p class="text-white/60 text-sm leading-relaxed max-w-xs">La puissance de la fibre locale.
La fiabilité, la performance et l'expertise à proximité pour propulser vos communications.</p>
</div>
<!-- Quick links -->
<div>
<h4 class="font-display font-bold mb-6 text-lg">Services</h4>
<ul class="space-y-3 text-sm text-white/60">
<li><a href="/internet" class="hover:text-targo-green transition-colors">Internet</a></li>
<li><a href="/television" class="hover:text-targo-green transition-colors">Télévision</a></li>
<li><a href="/telephone" class="hover:text-targo-green transition-colors">Téléphone</a></li>
<li><a href="/support" class="hover:text-targo-green transition-colors">Support</a></li>
</ul>
</div>
<!-- Company -->
<div>
<h4 class="font-display font-bold mb-6 text-lg">Plus</h4>
<ul class="space-y-3 text-sm text-white/60">
<li><a href="#apropos" class="hover:text-targo-green transition-colors">À propos</a></li>
<li><a href="#contact" class="hover:text-targo-green transition-colors">Contact</a></li>
<li><a href="https://store.targo.ca/clients" class="hover:text-targo-green transition-colors">Mon compte</a></li>
<li><a href="/politique-de-confidentialite" class="hover:text-targo-green transition-colors">Politique de confidentialité</a></li>
</ul>
</div>
<!-- Contact -->
<div>
<h4 class="font-sans font-bold mb-8 text-white uppercase tracking-widest text-xs">Nous joindre</h4>
<ul class="space-y-5 text-sm">
<li class="flex items-center gap-4 group">
<div class="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center group-hover:bg-targo-green group-hover:text-white transition-all duration-300">
<Phone class="w-5 h-5" />
</div>
<div class="flex flex-col">
<span class="text-white/50 text-[10px] uppercase font-bold tracking-wider">Appelez-nous</span>
<span class="text-white/80 font-medium group-hover:text-targo-green transition-colors">514-448-0773</span>
</div>
</li>
<li class="flex items-center gap-4 group">
<div class="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center group-hover:bg-targo-green group-hover:text-white transition-all duration-300">
<Mail class="w-5 h-5" />
</div>
<div class="flex flex-col">
<span class="text-white/50 text-[10px] uppercase font-bold tracking-wider">Écrivez-nous</span>
<span class="text-white/80 font-medium group-hover:text-targo-green transition-colors">support@targo.ca</span>
</div>
</li>
<li class="flex items-start gap-4 group">
<div class="w-10 h-10 rounded-xl bg-white/5 flex items-center justify-center group-hover:bg-targo-green group-hover:text-white transition-all duration-300 mt-1">
<MapPin class="w-5 h-5" />
</div>
<div class="flex flex-col">
<span class="text-white/50 text-[10px] uppercase font-bold tracking-wider">Visitez-nous</span>
<span class="text-white/80 font-medium leading-relaxed group-hover:text-targo-green transition-colors">
1867 chemin de la rivière<br />Ste-Clotilde, Québec
</span>
</div>
</li>
</ul>
</div>
</div>
<!-- Bottom -->
<div class="pt-12 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-6">
<p class="text-xs text-white/40 font-medium">
© {{ currentYear }} TARGO Communications. Tous droits réservés.
</p>
<div class="flex items-center gap-8 text-xs font-semibold uppercase tracking-wider text-white/40">
<a href="/accessibilite" class="hover:text-targo-green transition-all">Plan d'accessibilité</a>
<a href="/politique-de-confidentialite" class="hover:text-targo-green transition-all">Conditions d'utilisation</a>
</div>
</div>
</div>
</footer>
</template>

View File

@ -0,0 +1,369 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from "vue";
import { RouterLink } from "vue-router";
import {
Menu, X, Phone, Mail, ChevronDown, Search, User,
Wifi, PhoneCall, Building2, Signal, Server, Network,
Cable, Cloud, Shield, Settings, Laptop, Globe,
} from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import SearchDialog from "@/components/SearchDialog.vue";
import targoLogo from "@/assets/targo-logo.png";
// Business menu items with icons and descriptions
const businessMenuItems = [
{
category: "CONNECTIVITÉ",
items: [
{ label: "Internet affaires", href: "/business/internet", icon: Globe, description: "Connexion fibre haute vitesse pour entreprises" },
{ label: "SD-WAN", href: "/business/sd-wan", icon: Network, description: "Réseau intelligent multi-sites" },
{ label: "Réseaux inter-sites", href: "/business/reseau-inter-sites", icon: Cable, description: "Liaisons dédiées entre vos succursales" },
],
},
{
category: "COMMUNICATIONS",
items: [
{ label: "Téléphonie IP", href: "/business/telephonie", icon: PhoneCall, description: "Système téléphonique cloud complet" },
{ label: "Liaisons SIP", href: "/business/sip", icon: Phone, description: "Trunks SIP pour votre PBX" },
{ label: "Amplification cellulaire", href: "/business/amplification-cellulaire", icon: Signal, description: "Signal mobile amplifié partout" },
],
},
{
category: "INFRASTRUCTURE",
items: [
{ label: "Services cloud", href: "/business/cloud", icon: Cloud, description: "Hébergement et sauvegarde sécurisés" },
{ label: "Virtualisation", href: "/business/virtualisation", icon: Server, description: "Infrastructure virtualisée sur mesure" },
{ label: "Microsoft 365", href: "/business/microsoft-365", icon: Laptop, description: "Suite Microsoft pour entreprises" },
],
},
{
category: "SERVICES",
items: [
{ label: "WiFi commercial", href: "/business/wifi", icon: Wifi, description: "WiFi haute densité pour commerces" },
{ label: "Câblage structuré", href: "/business/cablage", icon: Cable, description: "Installation réseau professionnelle" },
{ label: "Services gérés", href: "/business/services-geres", icon: Settings, description: "Gestion TI complète externalisée" },
{ label: "Sécurité DDoS", href: "/business/securite", icon: Shield, description: "Protection contre les cyberattaques" },
{ label: "Multilogement", href: "/business/multilogement", icon: Building2, description: "Solutions pour immeubles résidentiels" },
],
},
];
interface NavLink {
label: string;
href: string;
isMegaMenu?: boolean;
submenu?: { label: string; href: string }[];
}
const navLinks: NavLink[] = [
{ label: "Internet", href: "/internet" },
{ label: "Télévision", href: "/television" },
{ label: "Téléphone", href: "/telephone" },
{ label: "Affaires", href: "/business", isMegaMenu: true },
{
label: "À propos",
href: "/support",
submenu: [
{ label: "Actualités", href: "/actualites" },
{ label: "Support", href: "/support" },
],
},
];
const businessMenuItemsFlat = businessMenuItems.flatMap((cat) => cat.items);
const isMenuOpen = ref(false);
const isSearchOpen = ref(false);
const isScrolled = ref(false);
const handleScroll = () => {
isScrolled.value = window.scrollY > 20;
};
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
isSearchOpen.value = !isSearchOpen.value;
}
};
onMounted(() => {
window.addEventListener("scroll", handleScroll);
document.addEventListener("keydown", down);
});
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
document.removeEventListener("keydown", down);
document.body.style.overflow = "";
});
// Lock body scroll when mobile menu is open
watch(isMenuOpen, (open) => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
});
</script>
<template>
<header :class="`fixed top-0 left-0 right-0 z-50 transition-all duration-500 ${isScrolled ? 'bg-white/90 backdrop-blur-xl shadow-2xl py-0' : 'bg-white py-0'}`">
<SearchDialog v-model:open="isSearchOpen" />
<!-- Top bar -->
<div
v-motion
:animate="{ height: isScrolled ? 0 : 'auto', opacity: isScrolled ? 0 : 1, transition: { duration: 300 } }"
class="bg-targo-green text-white py-2 px-4 shadow-md z-50 relative overflow-hidden hidden md:block"
>
<div class="container flex items-center justify-between text-xs font-semibold uppercase tracking-wider">
<div class="flex items-center gap-8">
<a href="tel:514.448.0773" class="flex items-center gap-2 hover:text-white/80 transition-colors">
<Phone class="w-3.5 h-3.5" />
<span>514.448.0773</span>
</a>
<a href="mailto:support@targo.ca" class="flex items-center gap-2 hover:text-white/80 transition-colors">
<Mail class="w-3.5 h-3.5" />
<span>support@targo.ca</span>
</a>
</div>
<span class="opacity-90">La puissance de la fibre locale</span>
</div>
</div>
<!-- Main nav -->
<nav :class="`container py-4 transition-all duration-300 ${isScrolled ? 'py-2' : 'py-4'}`">
<div class="flex items-center justify-between">
<!-- Logo -->
<RouterLink to="/" class="flex items-center group">
<img
:src="targoLogo"
alt="Targo - 100% Fibre"
class="transition-transform group-hover:scale-105 flex-shrink-0 object-contain"
:style="{ height: isScrolled ? '32px' : '40px', width: 'auto' }"
/>
</RouterLink>
<!-- Desktop nav -->
<div class="hidden md:flex items-center gap-8">
<template v-for="link in navLinks" :key="link.href">
<Popover v-if="link.isMegaMenu">
<PopoverTrigger class="flex items-center gap-1 text-gray-700 hover:text-targo-green transition-colors font-medium text-sm lg:text-base outline-none">
{{ link.label }}
<ChevronDown class="h-4 w-4" />
</PopoverTrigger>
<PopoverContent
class="w-[800px] p-6 bg-white border-gray-100 shadow-2xl rounded-xl"
align="center"
:side-offset="20"
>
<div class="grid grid-cols-4 gap-6">
<div v-for="category in businessMenuItems" :key="category.category" class="space-y-3">
<h4 class="text-xs font-bold text-gray-400 uppercase tracking-wider">
{{ category.category }}
</h4>
<div class="space-y-1">
<RouterLink
v-for="item in category.items"
:key="item.href"
:to="item.href"
class="group flex items-start gap-3 p-2 rounded-lg hover:bg-gray-50 transition-colors"
>
<div class="w-8 h-8 rounded-md bg-targo-green/10 flex items-center justify-center flex-shrink-0 group-hover:bg-targo-green/20 transition-colors">
<component :is="item.icon" class="w-4 h-4 text-targo-green" />
</div>
<div class="min-w-0">
<div class="font-medium text-gray-900 text-sm group-hover:text-targo-green transition-colors">
{{ item.label }}
</div>
<div class="text-xs text-gray-500 line-clamp-2">
{{ item.description }}
</div>
</div>
</RouterLink>
</div>
</div>
</div>
<div class="mt-6 pt-4 border-t border-gray-100">
<RouterLink
to="/business"
class="inline-flex items-center text-sm font-medium text-targo-green hover:text-targo-green/80 transition-colors"
>
Voir toutes les solutions affaires
<ChevronDown class="w-4 h-4 ml-1 rotate-[-90deg]" />
</RouterLink>
</div>
</PopoverContent>
</Popover>
<DropdownMenu v-else-if="link.submenu">
<DropdownMenuTrigger class="flex items-center gap-1 text-gray-700 hover:text-targo-green transition-colors font-medium text-sm lg:text-base outline-none">
{{ link.label }}
<ChevronDown class="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent class="bg-white border-gray-100 text-gray-800 shadow-xl">
<DropdownMenuItem
v-for="sublink in link.submenu"
:key="sublink.href"
class="focus:bg-gray-50 focus:text-targo-green cursor-pointer"
as-child
>
<RouterLink :to="sublink.href" class="w-full">
{{ sublink.label }}
</RouterLink>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<RouterLink
v-else
:to="link.href"
class="text-gray-700 hover:text-targo-green transition-colors font-medium text-sm lg:text-base"
>
{{ link.label }}
</RouterLink>
</template>
</div>
<!-- CTA -->
<div class="hidden md:flex items-center gap-4">
<Button
variant="ghost"
size="icon"
class="text-gray-700 hover:text-black hover:bg-black/5"
@click="isSearchOpen = true"
>
<Search class="w-5 h-5" />
</Button>
<Button variant="outline" class="border-gray-200 text-gray-700 bg-transparent hover:bg-gray-100 hover:text-black backdrop-blur-sm" as-child>
<a href="https://store.targo.ca/clients" target="_blank" rel="noopener noreferrer">Mon compte</a>
</Button>
<Button class="gradient-targo text-white shadow-lg shadow-targo-green/20 glow-targo border-none" as-child>
<a href="/#contact">Nous joindre</a>
</Button>
</div>
<!-- Mobile menu button -->
<div class="flex items-center gap-2 md:hidden">
<button
class="p-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
aria-label="Search"
@click="isSearchOpen = true"
>
<Search class="w-6 h-6" />
</button>
<button
class="p-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
aria-label="Toggle menu"
@click="isMenuOpen = !isMenuOpen"
>
<X v-if="isMenuOpen" class="w-6 h-6" />
<Menu v-else class="w-6 h-6" />
</button>
</div>
</div>
<!-- Mobile menu -->
<Transition>
<div
v-if="isMenuOpen"
v-motion
:initial="{ height: 0, opacity: 0 }"
:enter="{ height: '100vh', opacity: 1 }"
:leave="{ height: 0, opacity: 0 }"
class="md:hidden overflow-y-auto bg-white absolute left-0 right-0 top-full px-6 shadow-2xl border-t border-gray-100 z-50"
>
<div class="flex flex-col gap-6 py-10">
<div
v-for="(link, idx) in navLinks"
:key="link.href"
v-motion
:initial="{ x: -20, opacity: 0 }"
:enter="{ x: 0, opacity: 1, transition: { delay: idx * 50 } }"
>
<div v-if="link.isMegaMenu" class="flex flex-col gap-4">
<span class="text-gray-900 font-bold text-lg">{{ link.label }}</span>
<div class="grid grid-cols-2 gap-3">
<RouterLink
v-for="item in businessMenuItemsFlat"
:key="item.href"
:to="item.href"
class="flex items-center gap-2 text-gray-600 hover:text-targo-green transition-colors text-sm"
@click="isMenuOpen = false"
>
<component :is="item.icon" class="w-4 h-4 text-targo-green" />
{{ item.label }}
</RouterLink>
</div>
</div>
<div v-else-if="link.submenu" class="flex flex-col gap-4">
<span class="text-gray-900 font-bold text-lg">{{ link.label }}</span>
<div class="pl-4 flex flex-col gap-4 py-2 border-l-2 border-targo-green/20">
<RouterLink
v-for="sublink in link.submenu"
:key="sublink.href"
:to="sublink.href"
class="text-gray-600 hover:text-targo-green transition-colors text-base"
@click="isMenuOpen = false"
>
{{ sublink.label }}
</RouterLink>
</div>
</div>
<RouterLink
v-else
:to="link.href"
class="text-gray-900 hover:text-targo-green transition-colors font-bold text-lg"
@click="isMenuOpen = false"
>
{{ link.label }}
</RouterLink>
</div>
<div
v-motion
:initial="{ y: 20, opacity: 0 }"
:enter="{ y: 0, opacity: 1, transition: { delay: 300 } }"
class="flex flex-col gap-4 pt-10 border-t border-gray-100"
>
<Button variant="outline" class="w-full border-gray-200 text-gray-700 hover:bg-gray-50 bg-white h-14 text-lg font-semibold shadow-sm" as-child>
<a href="https://store.targo.ca/clients" target="_blank" rel="noopener noreferrer">
<User class="w-5 h-5 mr-2" />
Mon compte
</a>
</Button>
<Button class="w-full gradient-targo text-white h-14 text-lg font-bold glow-targo border-none" as-child>
<a href="/#contact">Nous joindre</a>
</Button>
<div class="flex flex-col gap-3 mt-4 text-gray-500">
<a href="tel:514.448.0773" class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-targo-green/10 flex items-center justify-center">
<Phone class="w-5 h-5 text-targo-green" />
</div>
<span class="font-semibold text-gray-900">514.448.0773</span>
</a>
<a href="mailto:support@targo.ca" class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-targo-green/10 flex items-center justify-center">
<Mail class="w-5 h-5 text-targo-green" />
</div>
<span class="font-semibold text-gray-900">support@targo.ca</span>
</a>
</div>
</div>
</div>
</div>
</Transition>
</nav>
</header>
</template>

View File

@ -0,0 +1,83 @@
<script setup lang="ts">
import { Users, MapPin, Building2, Heart } from "lucide-vue-next";
import targoBureaux from "@/assets/targo-bureaux.jpg";
const features = [{
icon: MapPin,
title: "Fibre 100% locale",
description: "Un réseau fibre optique indépendant, conçu et géré localement pour notre communauté."
}, {
icon: Users,
title: "Service personnalisé",
description: "Une équipe dévouée qui connaît chaque client. Un service humain, proche de vous."
}, {
icon: Building2,
title: "Économie locale",
description: "Nous créons des emplois de qualité et soutenons les organismes et écoles de la région."
}, {
icon: Heart,
title: "Engagement communautaire",
description: "Fiers partenaires des événements locaux, nous redonnons à notre communauté."
}];
</script>
<template>
<section id="apropos" class="py-20 md:py-32">
<div class="container">
<div class="grid lg:grid-cols-2 gap-16 items-center">
<!-- Image/Visual side -->
<div class="relative">
<div class="relative rounded-3xl overflow-hidden aspect-[4/3]">
<img
:src="targoBureaux"
alt="Bureaux TARGO à Sainte-Clotilde"
class="w-full h-full object-cover"
/>
</div>
<!-- Stats card -->
<div class="absolute -bottom-6 -left-6 bg-card p-6 rounded-2xl shadow-xl border border-border">
<div class="font-display text-4xl font-bold text-primary mb-1">Ste-Clotilde</div>
<div class="text-muted-foreground">Québec, Canada</div>
</div>
</div>
<!-- Content side -->
<div class="space-y-8">
<div>
<span class="inline-block px-4 py-1 bg-primary/10 rounded-full text-primary font-medium text-sm mb-4">
À propos
</span>
<h2 class="font-display text-4xl md:text-5xl font-bold mb-6">
100% fibre <span class="text-gradient-targo">locale</span>
</h2>
<p class="text-muted-foreground text-lg mb-4">
Fondée il y a 20 ans par un citoyen local, TARGO est née pour connecter les citoyens d'ici,
à une époque les grands fournisseurs ne s'y intéressaient pas. Au fil des années, nous avons
branché des milliers de familles et d'entreprises de la région, en offrant une connexion fiable
et des prix stables.
</p>
<p class="text-muted-foreground">
En choisissant TARGO, vous soutenez un service local qui réinvestit chaque année plus de
100 000 $ dans la communauté et favorise l'embauche de talents d'ici, incluant des emplois
d'été et des postes liés à l'innovation.
</p>
</div>
<!-- Features grid -->
<div class="grid sm:grid-cols-2 gap-6 pt-4">
<div v-for="feature in features" :key="feature.title" class="flex gap-4">
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0">
<component :is="feature.icon" class="w-6 h-6 text-primary" />
</div>
<div>
<h3 class="font-display font-semibold mb-1">{{ feature.title }}</h3>
<p class="text-sm text-muted-foreground">{{ feature.description }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
const clients = [
"La Prairie",
"Parc Safari",
"Vegpro Int'l",
"IGA",
"Sobeys",
"ASFC Canada",
];
</script>
<template>
<section class="py-16 border-y border-border bg-muted/30">
<div class="container">
<div class="text-center mb-12">
<h3 class="font-display text-2xl font-bold mb-2">
Des milliers de clients font notre référence
</h3>
<p class="text-muted-foreground">
TARGO est devenue incontournable pour les communications de plusieurs milliers de clients
</p>
</div>
<div class="flex flex-wrap justify-center items-center gap-8 md:gap-16">
<div
v-for="client in clients"
:key="client"
class="px-6 py-3 bg-card rounded-lg border border-border text-muted-foreground font-medium hover:text-primary hover:border-primary/50 transition-colors"
>
{{ client }}
</div>
</div>
</div>
</section>
</template>

View File

@ -0,0 +1,188 @@
<script setup lang="ts">
import { Phone, Mail, MapPin, Clock, Send } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ref } from "vue";
import { useToast } from "@/components/ui/toast/use-toast";
const contactInfo = [
{
icon: Phone,
label: "Téléphone",
value: "1-855-88-TARGO",
subValue: "514-448-0773",
},
{
icon: Mail,
label: "Courriel",
value: "support@targo.ca",
},
{
icon: MapPin,
label: "Adresse",
value: "1867 chemin de la rivière",
subValue: "Ste-Clotilde, Québec J0L 1W0",
},
{
icon: Clock,
label: "Heures d'ouverture",
value: "Lun-Ven: 8h00 20h00",
subValue: "Sam-Dim: 8h00 16h00",
},
];
const { toast } = useToast();
const formData = ref({
name: "",
address: "",
postalCode: "",
email: "",
phone: "",
message: "",
});
const sending = ref(false);
const handleSubmit = async () => {
sending.value = true;
try {
await fetch("/rpc/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData.value),
});
toast({
title: "Message envoyé!",
description: "Nous vous répondrons dans les plus brefs délais.",
});
formData.value = { name: "", address: "", postalCode: "", email: "", phone: "", message: "" };
} catch {
toast({ title: "Erreur", description: "Impossible d'envoyer. Réessayez ou appelez-nous.", variant: "destructive" });
}
sending.value = false;
};
</script>
<template>
<section id="contact" class="py-20 md:py-32 bg-secondary/30">
<div class="container">
<div class="text-center max-w-2xl mx-auto mb-16">
<span class="inline-block px-4 py-1 bg-primary/10 rounded-full text-primary font-medium text-sm mb-4">
Contactez-nous
</span>
<h2 class="font-display text-4xl md:text-5xl font-bold mb-4">
On aime <span class="text-gradient-targo">aider</span>
</h2>
<p class="text-muted-foreground text-lg">
Nous vous invitons à nous contacter pour bien évaluer votre situation
et vous faire profiter des meilleurs forfaits.
</p>
</div>
<div class="grid lg:grid-cols-5 gap-12">
<!-- Contact form -->
<div class="lg:col-span-3">
<form @submit.prevent="handleSubmit" class="bg-card rounded-3xl p-8 shadow-lg border border-border">
<div class="grid sm:grid-cols-2 gap-6 mb-6">
<div class="space-y-2">
<label for="name" class="text-sm font-medium">Nom</label>
<Input
id="name"
name="name"
placeholder="Votre nom"
v-model="formData.name"
required
/>
</div>
<div class="space-y-2">
<label for="email" class="text-sm font-medium">Courriel</label>
<Input
id="email"
name="email"
type="email"
placeholder="votre@courriel.com"
v-model="formData.email"
required
/>
</div>
</div>
<div class="grid sm:grid-cols-2 gap-6 mb-6">
<div class="space-y-2">
<label for="address" class="text-sm font-medium">Adresse ( le service est livré)</label>
<Input
id="address"
name="address"
placeholder="123 rue Principale"
v-model="formData.address"
required
/>
</div>
<div class="space-y-2">
<label for="postalCode" class="text-sm font-medium">Code postal</label>
<Input
id="postalCode"
name="postalCode"
placeholder="J0L 1W0"
v-model="formData.postalCode"
required
/>
</div>
</div>
<div class="space-y-2 mb-6">
<label for="phone" class="text-sm font-medium">Téléphone</label>
<Input
id="phone"
name="phone"
type="tel"
placeholder="514-000-0000"
v-model="formData.phone"
/>
</div>
<div class="space-y-2 mb-6">
<label for="message" class="text-sm font-medium">Message</label>
<Textarea
id="message"
name="message"
placeholder="Comment pouvons-nous vous aider?"
:rows="4"
v-model="formData.message"
required
/>
</div>
<Button type="submit" size="lg" class="w-full gradient-targo">
<Send class="w-5 h-5 mr-2" />
Envoyer
</Button>
</form>
</div>
<!-- Contact info -->
<div class="lg:col-span-2 space-y-6">
<div v-for="item in contactInfo" :key="item.label" class="flex gap-4 p-4 bg-card rounded-2xl border border-border">
<div class="w-12 h-12 rounded-xl gradient-targo flex items-center justify-center flex-shrink-0">
<component :is="item.icon" class="w-6 h-6 text-primary-foreground" />
</div>
<div>
<div class="text-sm text-muted-foreground mb-1">{{ item.label }}</div>
<div class="font-medium">{{ item.value }}</div>
<div v-if="item.subValue" class="text-sm text-muted-foreground">{{ item.subValue }}</div>
</div>
</div>
<div class="p-6 bg-primary/10 rounded-2xl">
<div class="font-display font-semibold mb-2">Surveillance 24/7/365</div>
<p class="text-sm text-muted-foreground">
Notre équipe surveille le réseau et répond aux urgences 24 heures sur 24,
7 jours sur 7, toute l'année.
</p>
</div>
</div>
</div>
</div>
</section>
</template>

View File

@ -0,0 +1,161 @@
<script setup lang="ts">
import { ref } from "vue";
import { ArrowRight, Shield, Zap, Clock } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import heroFamily from "@/assets/hero-family-3d.png";
import AvailabilityDialog from "@/components/AvailabilityDialog.vue";
const availOpen = ref(false);
</script>
<template>
<section id="accueil" class="relative pt-48 pb-24 md:pt-56 md:pb-40 overflow-hidden bg-white">
<!-- Decorative background gradients -->
<div class="absolute top-0 left-1/4 w-[600px] h-[600px] bg-targo-green/5 rounded-full blur-[120px] -translate-y-1/2 pointer-events-none" />
<div class="absolute bottom-0 right-0 w-[800px] h-[800px] bg-targo-green/5 rounded-full blur-[150px] translate-y-1/2 translate-x-1/2 pointer-events-none" />
<div class="container relative">
<div class="grid lg:grid-cols-2 gap-12 items-center">
<!-- Content -->
<div class="space-y-10 relative z-10">
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:enter="{ opacity: 1, y: 0, transition: { duration: 600 } }"
class="inline-flex items-center gap-3 px-4 py-2 bg-targo-green/10 rounded-full text-targo-green-dark font-bold text-xs uppercase tracking-widest"
>
<div class="w-2 h-2 rounded-full bg-targo-green animate-pulse" />
<span>Réseau 100% Fibre Optique Locale</span>
</div>
<div class="space-y-4">
<h1
v-motion
:initial="{ opacity: 0, x: -30 }"
:enter="{ opacity: 1, x: 0, transition: { duration: 800, delay: 200 } }"
class="font-display text-6xl md:text-7xl lg:text-8xl font-black leading-[0.9] tracking-tighter text-foreground"
>
Le futur est
<br />
<span class="text-gradient-targo">branché ici.</span>
</h1>
<p
v-motion
:initial="{ opacity: 0 }"
:enter="{ opacity: 1, transition: { duration: 1000, delay: 400 } }"
class="text-xl text-muted-foreground max-w-lg leading-relaxed font-sans"
>
Vitesse Gigabit, fiabilité absolue et service régional.
Découvrez la puissance de la fibre optique de nouvelle génération.
</p>
</div>
<div
v-motion
:initial="{ opacity: 0, y: 30 }"
:enter="{ opacity: 1, y: 0, transition: { duration: 600, delay: 600 } }"
class="flex flex-col sm:flex-row gap-4"
>
<Button size="lg" class="gradient-targo text-lg px-8 py-7 glow-targo group h-16 sm:h-auto">
Voir nos forfaits
<ArrowRight class="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
</Button>
<Button size="lg" variant="outline" @click="availOpen = true" class="text-lg px-8 py-7 border-2 border-border text-foreground hover:border-targo-green hover:text-targo-green hover:bg-targo-green/5 h-16 sm:h-auto transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
Vérifier la disponibilité
</Button>
<AvailabilityDialog v-model:open="availOpen" />
</div>
<!-- Trust indicators -->
<div
v-motion
:initial="{ opacity: 0 }"
:enter="{ opacity: 1, transition: { duration: 1000, delay: 800 } }"
class="grid grid-cols-3 gap-8 pt-8 border-t border-border"
>
<div class="space-y-1">
<div class="font-display text-4xl font-black text-foreground">20+</div>
<div class="text-[10px] uppercase font-bold tracking-widest text-muted-foreground">Années d'expertise</div>
</div>
<div class="space-y-1">
<div class="font-display text-4xl font-black text-foreground">10k+</div>
<div class="text-[10px] uppercase font-bold tracking-widest text-muted-foreground">Clients satisfaits</div>
</div>
<div class="space-y-1">
<div class="font-display text-4xl font-black text-foreground">7j/7</div>
<div class="text-[10px] uppercase font-bold tracking-widest text-muted-foreground">Support Local</div>
</div>
</div>
</div>
<!-- Visual -->
<div class="relative hidden lg:block">
<div
v-motion
:initial="{ opacity: 0, scale: 0.9, x: 50 }"
:enter="{ opacity: 1, scale: 1, x: 0, transition: { duration: 1000, delay: 300 } }"
class="relative w-full max-w-xl mx-auto"
>
<div class="relative">
<img :src="heroFamily" alt="Famille heureuse profitant de la télévision et d'Internet" class="w-full h-auto object-cover" />
</div>
<!-- Floating Elements (Glassmorphism) -->
<div
v-motion
:initial="{ y: 0 }"
:enter="{ y: [0, -10, 0], transition: { duration: 4000, repeat: Infinity, ease: 'easeInOut' } }"
class="absolute -top-6 -left-6 glass-card px-6 py-4 rounded-3xl z-20"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-targo-green/20 flex items-center justify-center">
<Zap class="w-5 h-5 text-targo-green" />
</div>
<div>
<div class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Vitesse</div>
<div class="font-display font-black text-foreground">Gigabit+</div>
</div>
</div>
</div>
<div
v-motion
:initial="{ y: 0 }"
:enter="{ y: [0, 10, 0], transition: { duration: 5000, repeat: Infinity, ease: 'easeInOut' } }"
class="absolute top-1/2 -right-10 glass-card px-6 py-4 rounded-3xl z-20"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-targo-green/20 flex items-center justify-center">
<Shield class="w-5 h-5 text-targo-green" />
</div>
<div>
<div class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Réseau</div>
<div class="font-display font-black text-foreground">Sécurisé</div>
</div>
</div>
</div>
<div
v-motion
:initial="{ x: 0 }"
:enter="{ x: [0, 5, 0], transition: { duration: 6000, repeat: Infinity, ease: 'easeInOut' } }"
class="absolute -bottom-6 left-1/4 glass-card px-6 py-4 rounded-3xl z-20"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-targo-green/20 flex items-center justify-center">
<Clock class="w-5 h-5 text-targo-green" />
</div>
<div class="flex items-center gap-2">
<div class="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">Support</div>
<div class="font-display font-black text-foreground">Local 7j/7</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>

View File

@ -0,0 +1,75 @@
<script setup lang="ts">
import { CheckCircle, ArrowRight } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
interface PricingTier {
name: string;
price: string;
description: string;
features: string[];
popular?: boolean;
promo?: string;
}
withDefaults(defineProps<{
title?: string;
subtitle?: string;
tiers: PricingTier[];
}>(), {
title: "Nos forfaits",
subtitle: "Simple et transparent",
});
</script>
<template>
<section class="py-16 md:py-24 bg-muted/30">
<div class="container mx-auto px-4">
<div v-if="title || subtitle" class="text-center max-w-2xl mx-auto mb-12">
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-4 font-display">{{ title }}</h2>
<p class="text-muted-foreground">{{ subtitle }}</p>
</div>
<div class="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
<Card
v-for="(tier, index) in tiers"
:key="index"
:class="`relative ${tier.popular ? 'border-primary border-2 shadow-xl' : ''}`"
>
<span v-if="tier.popular" class="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground px-4 py-1 rounded-full text-sm font-medium">
Recommandé
</span>
<CardHeader class="text-center pt-8">
<CardTitle class="text-2xl">{{ tier.name }}</CardTitle>
<CardDescription>{{ tier.description }}</CardDescription>
<div class="mt-4">
<span class="text-4xl font-bold text-primary">{{ tier.price }}</span>
<span class="text-muted-foreground text-sm">/mois</span>
</div>
</CardHeader>
<CardContent>
<ul class="space-y-3 mb-6">
<li v-for="(feature, i) in tier.features" :key="i" class="flex items-start gap-3 text-sm text-muted-foreground">
<CheckCircle class="w-5 h-5 text-primary flex-shrink-0" />
<span>{{ feature }}</span>
</li>
</ul>
<Button
class="w-full"
:variant="tier.popular ? 'default' : 'outline'"
as-child
>
<a href="/#contact">Choisir ce forfait <ArrowRight class="w-4 h-4 ml-2" /></a>
</Button>
<p v-if="tier.promo" class="text-xs text-primary text-center font-medium mt-4">
{{ tier.promo }}
</p>
</CardContent>
</Card>
</div>
</div>
</section>
</template>

View File

@ -0,0 +1,142 @@
<script setup lang="ts">
import { Wifi, Tv, Phone, ArrowRight, Check } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { RouterLink } from "vue-router";
const services = [{
icon: Wifi,
title: "Internet",
description: "La meilleure connexion sur la fibre",
price: 39.95,
unit: "/mois",
features: ["Vitesse jusqu'à 1.5 Gbps", "Données illimitées", "WiFi et support inclus"],
popular: true,
link: "/internet"
}, {
icon: Tv,
title: "Télévision",
description: "La télé allumée par la fibre",
price: 25,
unit: "/mois +tx",
features: ["80+ chaînes HD", "Récepteur fourni", "Enregistrement numérique"],
popular: false,
link: "/television"
}, {
icon: Phone,
title: "Téléphone",
description: "Ligne résidentielle, tout inclus",
price: 10,
unit: "/mois +tx",
features: ["Appels illimités", "Canada et États-Unis", "Afficheur inclus"],
popular: false,
link: "/telephone"
}];
const centsOf = (price: number) => ((price % 1) * 100).toFixed(0).padStart(2, "0");
</script>
<template>
<section id="services" class="py-24 md:py-40 bg-gradient-to-br from-secondary via-background to-secondary relative overflow-hidden">
<!-- Decorative background gradients -->
<div class="absolute top-1/2 left-0 w-[500px] h-[500px] bg-primary/5 rounded-full blur-[120px] -translate-x-1/2 pointer-events-none" />
<div class="container">
<!-- Section header -->
<div class="text-center max-w-2xl mx-auto mb-16">
<span
v-motion
:initial="{ opacity: 0, y: 10 }"
:visible-once="{ opacity: 1, y: 0 }"
class="inline-block px-4 py-1 bg-primary/10 rounded-full text-primary font-medium text-sm mb-4"
>
Nos solutions fibre
</span>
<h2
v-motion
:initial="{ opacity: 0, y: 20 }"
:visible-once="{ opacity: 1, y: 0, transition: { delay: 100 } }"
class="font-display text-4xl md:text-5xl font-bold mb-6"
>
Des forfaits pour <span class="text-gradient-targo">tous les besoins</span>
</h2>
<p
v-motion
:initial="{ opacity: 0 }"
:visible-once="{ opacity: 1, transition: { delay: 200 } }"
class="text-muted-foreground text-lg leading-relaxed"
>
Internet haute vitesse, télévision HD et téléphonie résidentielle.
Le tout alimenté par notre réseau 100% fibre optique régional.
</p>
</div>
<!-- Service cards -->
<div class="grid md:grid-cols-3 gap-8 max-w-5xl mx-auto">
<div
v-for="(service, index) in services"
:key="service.title"
v-motion
:initial="{ opacity: 0, y: 30 }"
:visible-once="{ opacity: 1, y: 0, transition: { delay: index * 100 } }"
>
<Card :class="`relative overflow-hidden transition-all duration-300 hover:shadow-xl hover:-translate-y-1 ${
service.popular ? 'border-primary shadow-lg ring-2 ring-primary/20' : ''
}`">
<div v-if="service.popular" class="absolute top-4 right-4 z-10 px-3 py-1 gradient-targo text-primary-foreground text-xs font-medium rounded-full">
Populaire
</div>
<CardContent class="p-8 flex flex-col h-full">
<div :class="`w-14 h-14 rounded-2xl flex items-center justify-center mb-6 ${
service.popular ? 'gradient-targo text-primary-foreground' : 'bg-primary/10 text-primary'
}`">
<component :is="service.icon" class="w-7 h-7" />
</div>
<h3 class="font-display text-2xl font-bold mb-2">{{ service.title }}</h3>
<p class="text-muted-foreground text-sm mb-6 min-h-[40px]">{{ service.description }}</p>
<div class="flex items-baseline gap-1">
<span class="font-display text-5xl font-bold text-primary">
${{ Math.floor(service.price) }}
<sup class="text-2xl">.{{ centsOf(service.price) }}</sup>
</span>
<span class="text-muted-foreground text-sm">{{ service.unit }}</span>
</div>
<p class="text-sm text-muted-foreground mb-4">à partir de</p>
<ul class="space-y-3 mb-8 flex-grow mt-4">
<li v-for="feature in service.features" :key="feature" class="flex items-center gap-3">
<div class="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Check class="w-3 h-3 text-primary" />
</div>
<span class="text-sm text-muted-foreground">{{ feature }}</span>
</li>
</ul>
<div class="mt-auto">
<Button
as-child
:class="`w-full ${service.popular ? 'gradient-targo' : ''}`"
:variant="service.popular ? 'default' : 'outline'"
>
<RouterLink :to="service.link">
Voir les détails
<ArrowRight class="w-4 h-4 ml-2" />
</RouterLink>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
<!-- Note -->
<p class="text-center text-sm text-muted-foreground mt-12 max-w-2xl mx-auto">
Les technologies et promotions disponibles varient parfois entre deux voisins.
Contactez-nous pour évaluer votre situation et obtenir le meilleur forfait pour vous.
</p>
</div>
</section>
</template>

View File

@ -0,0 +1,26 @@
<script setup lang="ts">
import { cn } from "@/lib/utils";
const props = withDefaults(
defineProps<{ class?: string; hoverEffect?: boolean }>(),
{ hoverEffect: true },
);
</script>
<template>
<div
v-motion
:initial="{ opacity: 0, y: 20 }"
:visible-once="{ opacity: 1, y: 0, transition: { duration: 500 } }"
:hovered="props.hoverEffect ? { scale: 1.02, transition: { duration: 200 } } : {}"
:class="cn(
'relative overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-md shadow-xl',
props.class,
)"
>
<div class="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent pointer-events-none" />
<div class="relative z-10">
<slot />
</div>
</div>
</template>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
withDefaults(
defineProps<{ class?: string; variant?: "default" | "white" }>(),
{ class: "h-10", variant: "default" },
);
</script>
<template>
<svg
viewBox="0 0 400 120"
:class="$props.class"
xmlns="http://www.w3.org/2000/svg"
fill="none"
>
<path
d="M40 20 H100 M70 20 V100"
:stroke="variant === 'white' ? 'white' : '#00C853'"
stroke-width="12"
stroke-linecap="round"
/>
<path
d="M120 100 L150 20 L180 100 M135 70 H165"
:stroke="variant === 'white' ? 'white' : '#00C853'"
stroke-width="12"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M200 100 V20 H250 A30 30 0 0 1 250 80 H200 M250 80 L280 100"
:stroke="variant === 'white' ? 'white' : '#00C853'"
stroke-width="12"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M300 20 H360 V100 H300 V60 H350"
:stroke="variant === 'white' ? 'white' : '#00C853'"
stroke-width="12"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M380 60 A40 40 0 1 1 380 59"
:stroke="variant === 'white' ? 'white' : '#00C853'"
stroke-width="12"
stroke-linecap="round"
/>
</svg>
</template>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { AccordionRootEmits, AccordionRootProps } from "reka-ui"
import {
AccordionRoot,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<AccordionRootProps>()
const emits = defineEmits<AccordionRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AccordionRoot v-bind="forwarded">
<slot />
</AccordionRoot>
</template>

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { AccordionContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AccordionContent } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AccordionContent
v-bind="delegatedProps"
class="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
>
<div :class="cn('pb-4 pt-0', props.class)">
<slot />
</div>
</AccordionContent>
</template>

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { AccordionItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AccordionItem, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AccordionItem
v-bind="forwardedProps"
:class="cn('border-b', props.class)"
>
<slot />
</AccordionItem>
</template>

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
import type { AccordionTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "lucide-vue-next"
import {
AccordionHeader,
AccordionTrigger,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
v-bind="delegatedProps"
:class="
cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
props.class,
)
"
>
<slot />
<slot name="icon">
<ChevronDown
class="h-4 w-4 shrink-0 transition-transform duration-200"
/>
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>

View File

@ -0,0 +1,4 @@
export { default as Accordion } from "./Accordion.vue"
export { default as AccordionContent } from "./AccordionContent.vue"
export { default as AccordionItem } from "./AccordionItem.vue"
export { default as AccordionTrigger } from "./AccordionTrigger.vue"

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { AlertDialogActionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogAction } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
import type { AlertDialogCancelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogCancel } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
props.class,
)"
>
<slot />
</AlertDialogCancel>
</template>

View File

@ -0,0 +1,38 @@
<script setup lang="ts">
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<AlertDialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { AlertDialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
AlertDialogDescription,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogDescription
v-bind="delegatedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</AlertDialogDescription>
</template>

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import type { AlertDialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogTitle } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogTitle
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { AlertDialogTriggerProps } from "reka-ui"
import { AlertDialogTrigger } from "reka-ui"
const props = defineProps<AlertDialogTriggerProps>()
</script>
<template>
<AlertDialogTrigger v-bind="props">
<slot />
</AlertDialogTrigger>
</template>

View File

@ -0,0 +1,9 @@
export { default as AlertDialog } from "./AlertDialog.vue"
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { AlertVariants } from "."
import { cn } from "@/lib/utils"
import { alertVariants } from "."
const props = defineProps<{
class?: HTMLAttributes["class"]
variant?: AlertVariants["variant"]
}>()
</script>
<template>
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
<slot />
</div>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h5 :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
<slot />
</h5>
</template>

View File

@ -0,0 +1,24 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Alert } from "./Alert.vue"
export { default as AlertDescription } from "./AlertDescription.vue"
export { default as AlertTitle } from "./AlertTitle.vue"
export const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type AlertVariants = VariantProps<typeof alertVariants>

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { AspectRatioProps } from "reka-ui"
import { AspectRatio } from "reka-ui"
const props = defineProps<AspectRatioProps>()
</script>
<template>
<AspectRatio v-bind="props">
<slot />
</AspectRatio>
</template>

View File

@ -0,0 +1 @@
export { default as AspectRatio } from "./AspectRatio.vue"

View File

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { AvatarVariants } from "."
import { AvatarRoot } from "reka-ui"
import { cn } from "@/lib/utils"
import { avatarVariant } from "."
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
size?: AvatarVariants["size"]
shape?: AvatarVariants["shape"]
}>(), {
size: "sm",
shape: "circle",
})
</script>
<template>
<AvatarRoot :class="cn(avatarVariant({ size, shape }), props.class)">
<slot />
</AvatarRoot>
</template>

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { AvatarFallbackProps } from "reka-ui"
import { AvatarFallback } from "reka-ui"
const props = defineProps<AvatarFallbackProps>()
</script>
<template>
<AvatarFallback v-bind="props">
<slot />
</AvatarFallback>
</template>

View File

@ -0,0 +1,12 @@
<script setup lang="ts">
import type { AvatarImageProps } from "reka-ui"
import { AvatarImage } from "reka-ui"
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage v-bind="props" class="h-full w-full object-cover">
<slot />
</AvatarImage>
</template>

View File

@ -0,0 +1,25 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Avatar } from "./Avatar.vue"
export { default as AvatarFallback } from "./AvatarFallback.vue"
export { default as AvatarImage } from "./AvatarImage.vue"
export const avatarVariant = cva(
"inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden",
{
variants: {
size: {
sm: "h-10 w-10 text-xs",
base: "h-16 w-16 text-2xl",
lg: "h-32 w-32 text-5xl",
},
shape: {
circle: "rounded-full",
square: "rounded-md",
},
},
},
)
export type AvatarVariants = VariantProps<typeof avatarVariant>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { cn } from "@/lib/utils"
import { badgeVariants } from "."
const props = defineProps<{
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn(badgeVariants({ variant }), props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,26 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Badge } from "./Badge.vue"
export const badgeVariants = cva(
"inline-flex gap-1 items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>

View File

@ -0,0 +1,13 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<nav aria-label="breadcrumb" :class="props.class">
<slot />
</nav>
</template>

View File

@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { MoreHorizontal } from "lucide-vue-next"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
role="presentation"
aria-hidden="true"
:class="cn('flex h-9 w-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="h-4 w-4" />
</slot>
<span class="sr-only">More</span>
</span>
</template>

View File

@ -0,0 +1,16 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<li
:class="cn('inline-flex items-center gap-1.5', props.class)"
>
<slot />
</li>
</template>

View File

@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>(), {
as: "a",
})
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn('transition-colors hover:text-foreground', props.class)"
>
<slot />
</Primitive>
</template>

View File

@ -0,0 +1,16 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<ol
:class="cn('flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5', props.class)"
>
<slot />
</ol>
</template>

View File

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
role="link"
aria-disabled="true"
aria-current="page"
:class="cn('font-normal text-foreground', props.class)"
>
<slot />
</span>
</template>

View File

@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { ChevronRight } from "lucide-vue-next"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<li
role="presentation"
aria-hidden="true"
:class="cn('[&>svg]:w-3.5 [&>svg]:h-3.5', props.class)"
>
<slot>
<ChevronRight />
</slot>
</li>
</template>

View File

@ -0,0 +1,7 @@
export { default as Breadcrumb } from "./Breadcrumb.vue"
export { default as BreadcrumbEllipsis } from "./BreadcrumbEllipsis.vue"
export { default as BreadcrumbItem } from "./BreadcrumbItem.vue"
export { default as BreadcrumbLink } from "./BreadcrumbLink.vue"
export { default as BreadcrumbList } from "./BreadcrumbList.vue"
export { default as BreadcrumbPage } from "./BreadcrumbPage.vue"
export { default as BreadcrumbSeparator } from "./BreadcrumbSeparator.vue"

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
</script>
<template>
<Primitive
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>

View File

@ -0,0 +1,37 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-10 px-4 py-2",
"sm": "h-9 rounded-md px-3",
"lg": "h-11 rounded-md px-8",
"icon": "h-10 w-10",
"icon-sm": "size-9",
"icon-lg": "size-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View File

@ -0,0 +1,58 @@
<script lang="ts" setup>
import type { CalendarRootEmits, CalendarRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from "."
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<CalendarRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CalendarRoot
v-slot="{ grid, weekDays }"
:class="cn('p-3', props.class)"
v-bind="forwarded"
>
<CalendarHeader>
<CalendarPrevButton />
<CalendarHeading />
<CalendarNextButton />
</CalendarHeader>
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell
v-for="day in weekDays" :key="day"
>
{{ day }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
<CalendarCell
v-for="weekDate in weekDates"
:key="weekDate.toString()"
:date="weekDate"
>
<CalendarCellTrigger
:day="weekDate"
:month="month.value"
/>
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</div>
</CalendarRoot>
</template>

View File

@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { CalendarCellProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarCell, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCell
:class="cn('relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarCell>
</template>

View File

@ -0,0 +1,36 @@
<script lang="ts" setup>
import type { CalendarCellTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarCellTrigger, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCellTrigger
:class="cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
props.class,
)"
v-bind="forwardedProps"
>
<slot />
</CalendarCellTrigger>
</template>

View File

@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { CalendarGridProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarGrid, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGrid
:class="cn('w-full border-collapse space-y-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarGrid>
</template>

View File

@ -0,0 +1,12 @@
<script lang="ts" setup>
import type { CalendarGridBodyProps } from "reka-ui"
import { CalendarGridBody } from "reka-ui"
const props = defineProps<CalendarGridBodyProps>()
</script>
<template>
<CalendarGridBody v-bind="props">
<slot />
</CalendarGridBody>
</template>

View File

@ -0,0 +1,12 @@
<script lang="ts" setup>
import type { CalendarGridHeadProps } from "reka-ui"
import { CalendarGridHead } from "reka-ui"
const props = defineProps<CalendarGridHeadProps>()
</script>
<template>
<CalendarGridHead v-bind="props">
<slot />
</CalendarGridHead>
</template>

View File

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { CalendarGridRowProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarGridRow, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
<slot />
</CalendarGridRow>
</template>

View File

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { CalendarHeadCellProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeadCell, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeadCell :class="cn('w-9 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)" v-bind="forwardedProps">
<slot />
</CalendarHeadCell>
</template>

View File

@ -0,0 +1,19 @@
<script lang="ts" setup>
import type { CalendarHeaderProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeader, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeader :class="cn('relative flex w-full items-center justify-between pt-1', props.class)" v-bind="forwardedProps">
<slot />
</CalendarHeader>
</template>

View File

@ -0,0 +1,29 @@
<script lang="ts" setup>
import type { CalendarHeadingProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { CalendarHeading, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes["class"] }>()
defineSlots<{
default: (props: { headingValue: string }) => any
}>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeading
v-slot="{ headingValue }"
:class="cn('text-sm font-medium', props.class)"
v-bind="forwardedProps"
>
<slot :heading-value>
{{ headingValue }}
</slot>
</CalendarHeading>
</template>

View File

@ -0,0 +1,30 @@
<script lang="ts" setup>
import type { CalendarNextProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronRight } from "lucide-vue-next"
import { CalendarNext, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarNext
:class="cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronRight class="h-4 w-4" />
</slot>
</CalendarNext>
</template>

View File

@ -0,0 +1,30 @@
<script lang="ts" setup>
import type { CalendarPrevProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronLeft } from "lucide-vue-next"
import { CalendarPrev, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarPrev
:class="cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronLeft class="h-4 w-4" />
</slot>
</CalendarPrev>
</template>

View File

@ -0,0 +1,12 @@
export { default as Calendar } from "./Calendar.vue"
export { default as CalendarCell } from "./CalendarCell.vue"
export { default as CalendarCellTrigger } from "./CalendarCellTrigger.vue"
export { default as CalendarGrid } from "./CalendarGrid.vue"
export { default as CalendarGridBody } from "./CalendarGridBody.vue"
export { default as CalendarGridHead } from "./CalendarGridHead.vue"
export { default as CalendarGridRow } from "./CalendarGridRow.vue"
export { default as CalendarHeadCell } from "./CalendarHeadCell.vue"
export { default as CalendarHeader } from "./CalendarHeader.vue"
export { default as CalendarHeading } from "./CalendarHeading.vue"
export { default as CalendarNextButton } from "./CalendarNextButton.vue"
export { default as CalendarPrevButton } from "./CalendarPrevButton.vue"

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
:class="
cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</p>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('flex items-center p-6 pt-0', props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
<slot />
</div>
</template>

View File

@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h3
:class="
cn('text-2xl font-semibold leading-none tracking-tight', props.class)
"
>
<slot />
</h3>
</template>

View File

@ -0,0 +1,6 @@
export { default as Card } from "./Card.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"

View File

@ -0,0 +1,52 @@
<script setup lang="ts">
import type { CarouselEmits, CarouselProps, WithClassAsProps } from "./interface"
import { cn } from "@/lib/utils"
import { useProvideCarousel } from "./useCarousel"
const props = withDefaults(defineProps<CarouselProps & WithClassAsProps>(), {
orientation: "horizontal",
})
const emits = defineEmits<CarouselEmits>()
const { canScrollNext, canScrollPrev, carouselApi, carouselRef, orientation, scrollNext, scrollPrev } = useProvideCarousel(props, emits)
defineExpose({
canScrollNext,
canScrollPrev,
carouselApi,
carouselRef,
orientation,
scrollNext,
scrollPrev,
})
function onKeyDown(event: KeyboardEvent) {
const prevKey = props.orientation === "vertical" ? "ArrowUp" : "ArrowLeft"
const nextKey = props.orientation === "vertical" ? "ArrowDown" : "ArrowRight"
if (event.key === prevKey) {
event.preventDefault()
scrollPrev()
return
}
if (event.key === nextKey) {
event.preventDefault()
scrollNext()
}
}
</script>
<template>
<div
:class="cn('relative', props.class)"
role="region"
aria-roledescription="carousel"
tabindex="0"
@keydown="onKeyDown"
>
<slot :can-scroll-next :can-scroll-prev :carousel-api :carousel-ref :orientation :scroll-next :scroll-prev />
</div>
</template>

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import type { WithClassAsProps } from "./interface"
import { cn } from "@/lib/utils"
import { useCarousel } from "./useCarousel"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<WithClassAsProps>()
const { carouselRef, orientation } = useCarousel()
</script>
<template>
<div ref="carouselRef" class="overflow-hidden">
<div
:class="
cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
props.class,
)"
v-bind="$attrs"
>
<slot />
</div>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More