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>
7
apps/website-vue/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
.vite/
|
||||||
|
*.log
|
||||||
79
apps/website-vue/PORTING_GUIDE.md
Normal 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).
|
||||||
20
apps/website-vue/components.json
Normal 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
|
|
@ -0,0 +1,7 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<{}, {}, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
24
apps/website-vue/index.html
Normal 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
44
apps/website-vue/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/website-vue/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
apps/website-vue/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 352 B |
1
apps/website-vue/public/placeholder.svg
Normal 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 |
14
apps/website-vue/public/robots.txt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
User-agent: Googlebot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Bingbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Twitterbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: facebookexternalhit
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
15
apps/website-vue/src/App.vue
Normal 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>
|
||||||
BIN
apps/website-vue/src/assets/hero-family-3d.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
apps/website-vue/src/assets/hero-family.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
apps/website-vue/src/assets/hero-family.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
114
apps/website-vue/src/assets/hero-family.svg
Normal 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 |
BIN
apps/website-vue/src/assets/hero-living-room-3d.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
apps/website-vue/src/assets/pain-buffering.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/website-vue/src/assets/pain-business.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
apps/website-vue/src/assets/solution-business.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/website-vue/src/assets/solution-connected.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
apps/website-vue/src/assets/targo-bureaux.jpg
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
1
apps/website-vue/src/assets/targo-logo-green.svg
Normal 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 |
1
apps/website-vue/src/assets/targo-logo-white.svg
Normal 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 |
BIN
apps/website-vue/src/assets/targo-logo.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
apps/website-vue/src/assets/targo-technician-avatar.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
413
apps/website-vue/src/components/AvailabilityDialog.vue
Normal 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>
|
||||||
73
apps/website-vue/src/components/ProtectedAdminRoute.vue
Normal 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>
|
||||||
108
apps/website-vue/src/components/SearchDialog.vue
Normal 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>
|
||||||
178
apps/website-vue/src/components/Speedometer.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
93
apps/website-vue/src/components/layout/Footer.vue
Normal 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>
|
||||||
369
apps/website-vue/src/components/layout/Header.vue
Normal 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>
|
||||||
83
apps/website-vue/src/components/sections/About.vue
Normal 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 où 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>
|
||||||
35
apps/website-vue/src/components/sections/Clients.vue
Normal 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>
|
||||||
188
apps/website-vue/src/components/sections/Contact.vue
Normal 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 (où 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>
|
||||||
161
apps/website-vue/src/components/sections/Hero.vue
Normal 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>
|
||||||
75
apps/website-vue/src/components/sections/Pricing.vue
Normal 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>
|
||||||
142
apps/website-vue/src/components/sections/Services.vue
Normal 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>
|
||||||
26
apps/website-vue/src/components/ui/GlassCard.vue
Normal 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>
|
||||||
49
apps/website-vue/src/components/ui/Logo.vue
Normal 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>
|
||||||
18
apps/website-vue/src/components/ui/accordion/Accordion.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
4
apps/website-vue/src/components/ui/accordion/index.ts
Normal 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"
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
9
apps/website-vue/src/components/ui/alert-dialog/index.ts
Normal 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"
|
||||||
17
apps/website-vue/src/components/ui/alert/Alert.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
14
apps/website-vue/src/components/ui/alert/AlertTitle.vue
Normal 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>
|
||||||
24
apps/website-vue/src/components/ui/alert/index.ts
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
1
apps/website-vue/src/components/ui/aspect-ratio/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as AspectRatio } from "./AspectRatio.vue"
|
||||||
22
apps/website-vue/src/components/ui/avatar/Avatar.vue
Normal 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>
|
||||||
12
apps/website-vue/src/components/ui/avatar/AvatarFallback.vue
Normal 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>
|
||||||
12
apps/website-vue/src/components/ui/avatar/AvatarImage.vue
Normal 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>
|
||||||
25
apps/website-vue/src/components/ui/avatar/index.ts
Normal 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>
|
||||||
17
apps/website-vue/src/components/ui/badge/Badge.vue
Normal 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>
|
||||||
26
apps/website-vue/src/components/ui/badge/index.ts
Normal 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>
|
||||||
13
apps/website-vue/src/components/ui/breadcrumb/Breadcrumb.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
7
apps/website-vue/src/components/ui/breadcrumb/index.ts
Normal 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"
|
||||||
28
apps/website-vue/src/components/ui/button/Button.vue
Normal 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>
|
||||||
37
apps/website-vue/src/components/ui/button/index.ts
Normal 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>
|
||||||
58
apps/website-vue/src/components/ui/calendar/Calendar.vue
Normal 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>
|
||||||
22
apps/website-vue/src/components/ui/calendar/CalendarCell.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
22
apps/website-vue/src/components/ui/calendar/CalendarGrid.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
12
apps/website-vue/src/components/ui/calendar/index.ts
Normal 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"
|
||||||
21
apps/website-vue/src/components/ui/card/Card.vue
Normal 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>
|
||||||
14
apps/website-vue/src/components/ui/card/CardContent.vue
Normal 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>
|
||||||
14
apps/website-vue/src/components/ui/card/CardDescription.vue
Normal 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>
|
||||||
14
apps/website-vue/src/components/ui/card/CardFooter.vue
Normal 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>
|
||||||
14
apps/website-vue/src/components/ui/card/CardHeader.vue
Normal 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>
|
||||||
18
apps/website-vue/src/components/ui/card/CardTitle.vue
Normal 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>
|
||||||
6
apps/website-vue/src/components/ui/card/index.ts
Normal 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"
|
||||||
52
apps/website-vue/src/components/ui/carousel/Carousel.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||