intial
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# openssl rand -base64 32
|
||||
NEXTAUTH_SECRET="mega-complex-password"
|
||||
9
.eslintrc.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"],
|
||||
"rules": {
|
||||
"@next/next/no-img-element": "off",
|
||||
"@typescript-eslint/no-empty-object-type": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off"
|
||||
}
|
||||
}
|
||||
|
||||
20
components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
1
google763a2192ab9a8564.html
Normal file
@@ -0,0 +1 @@
|
||||
google-site-verification: google763a2192ab9a8564.html
|
||||
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference types="next/navigation-types/compat/navigation" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
80
next.config.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||
formats: ['image/webp', 'image/avif'],
|
||||
minimumCacheTTL: 60,
|
||||
remotePatterns: [],
|
||||
domains: [],
|
||||
},
|
||||
swcMinify: true,
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
experimental: {
|
||||
optimizePackageImports: ['lucide-react'],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: [
|
||||
{
|
||||
key: 'X-Content-Type-Options',
|
||||
value: 'nosniff',
|
||||
},
|
||||
{
|
||||
key: 'X-XSS-Protection',
|
||||
value: '1; mode=block',
|
||||
},
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'SAMEORIGIN',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/:path*\\.jpg',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=31536000, immutable',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/:path*\\.png',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=31536000, immutable',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/index',
|
||||
destination: '/',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/home',
|
||||
destination: '/',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/accueil',
|
||||
destination: '/',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig;
|
||||
60
package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "next-starter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"author": "https://github.com/plvo",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@radix-ui/react-accordion": "^1.2.2",
|
||||
"@radix-ui/react-avatar": "^1.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-hover-card": "^1.1.2",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@tanstack/react-query": "^5.59.15",
|
||||
"bun": "^1.2.10",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dev": "^0.1.3",
|
||||
"lucide-react": "^0.453.0",
|
||||
"next": "14.2.15",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"run": "^1.5.0",
|
||||
"sharp": "^0.34.1",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/eslint-plugin-query": "^5.59.7",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"critters": "^0.0.25",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.15",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
public/activities/down.webp
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
public/activities/jump.webp
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
public/activities/slide.webp
Normal file
|
After Width: | Height: | Size: 115 KiB |
1
public/apple-touch-icon.png
Normal file
@@ -0,0 +1 @@
|
||||
/* Binary file - using logo.png from public/images/logo.png */
|
||||
BIN
public/gallery/1.webp
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
public/gallery/10.webp
Normal file
|
After Width: | Height: | Size: 415 KiB |
BIN
public/gallery/11.webp
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
public/gallery/2.webp
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
public/gallery/3.webp
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
public/gallery/4.webp
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
public/gallery/5.webp
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
public/gallery/6.webp
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/gallery/7.webp
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
public/gallery/8.webp
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
public/gallery/9.webp
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
public/images/hero-mobile.webp
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
public/images/hero.webp
Normal file
|
After Width: | Height: | Size: 647 KiB |
BIN
public/images/landscape.webp
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
public/images/logo.png
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
public/images/map.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
public/images/stef.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
23
public/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "Canyoning Sources & Nature",
|
||||
"short_name": "Sources & Nature",
|
||||
"description": "Découvrez des sorties canyoning uniques dans l'Ain et le Jura. Vivez des aventures en pleine nature adaptées à tous les niveaux.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#10b981",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/images/logo.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/images/logo.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
public/nature/animals/1.webp
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
public/nature/animals/2.webp
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
public/nature/animals/3.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
public/nature/plants/1.webp
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
public/nature/plants/2.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
public/nature/plants/3.webp
Normal file
|
After Width: | Height: | Size: 52 KiB |
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://www.sourcesetnature.fr/sitemap.xml
|
||||
9
public/sitemap.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://www.sourcesetnature.fr/</loc>
|
||||
<lastmod>2025-05-21</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
BIN
src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src/app/fonts/HardcoreAttitude.otf
Normal file
101
src/app/globals.css
Normal file
@@ -0,0 +1,101 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 345 6% 13%;
|
||||
--card: 345 6% 13%;
|
||||
--card-foreground: 0 0% 100%;
|
||||
--popover: 0 0% 4%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 103 47% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 196 100% 47%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--muted: 0 0% 15%;
|
||||
--muted-foreground: 0 0% 64%;
|
||||
--accent: 34 94% 54%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 63% 31%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 15%;
|
||||
--input: 0 0% 15%;
|
||||
--ring: 0 72% 51%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.625rem;
|
||||
}
|
||||
/* .dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
} */
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@font-face {
|
||||
font-family: "HardcoreAttitude";
|
||||
src: url("./fonts/HardcoreAttitude.otf");
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply font-poppins;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
@apply text-center uppercase text-5xl md:text-7xl xl:text-9xl font-hardcore;
|
||||
}
|
||||
|
||||
.main-description {
|
||||
@apply pb-10 text-center text-foreground text-lg max-w-[700px];
|
||||
}
|
||||
|
||||
.section-padding {
|
||||
@apply py-10;
|
||||
}
|
||||
271
src/app/layout.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import "./globals.css";
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { Providers } from "@/lib/providers";
|
||||
import { Poppins } from "next/font/google"
|
||||
import React from "react";
|
||||
|
||||
const hardcoreAttitude = localFont({
|
||||
src: "./fonts/HardcoreAttitude.otf",
|
||||
variable: "--font-hardcore",
|
||||
});
|
||||
const poppins = Poppins({
|
||||
variable: "--font-poppins",
|
||||
subsets: ["latin"],
|
||||
weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL('https://www.sourcesetnature.fr/'),
|
||||
title: {
|
||||
default: "Canyoning Jura et Ain | Sources & Nature - Guide Professionnel Saint-Claude",
|
||||
template: "%s | Canyoning Jura Ain - Sources & Nature"
|
||||
},
|
||||
description: "Guide de canyoning professionnel dans le Jura (Saint-Claude, Région des Lacs) et l'Ain (Ambérieu-en-Bugey). Sorties initiation et aventure : Grosdar, Coiserette, Chaley, Rhéby. Matériel fourni. 20 ans d'expérience.",
|
||||
keywords: ['canyoning Jura', 'canyoning Ain', 'canyoning Saint-Claude', 'canyoning Ambérieu-en-Bugey', 'guide canyoning Jura', 'guide canyoning Ain', 'canyon Grosdar', 'canyon Coiserette', 'canyon Chaley', 'canyon Rhéby', 'canyon Malvaux', 'canyon Langouette', 'canyoning Région des Lacs Jura', 'sources et nature', 'Stéphane guide canyoning', 'sorties canyoning Jura', 'sorties canyoning Ain', 'toboggans naturels Jura', 'descente rappel Jura', 'sauts canyon Ain', 'canyoning débutant Jura', 'canyoning aventure Ain', 'activités outdoor Jura', 'sport nature Ain Jura'],
|
||||
authors: [{ name: 'KODY.', url: 'https://itskody.fr/' }],
|
||||
creator: 'KODY',
|
||||
publisher: 'KODY',
|
||||
formatDetection: {
|
||||
email: false,
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.ico', sizes: 'any' }
|
||||
]
|
||||
},
|
||||
manifest: '/manifest.json',
|
||||
alternates: {
|
||||
canonical: 'https://www.sourcesetnature.fr/'
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'fr_FR',
|
||||
url: 'https://www.sourcesetnature.fr/',
|
||||
title: "Canyoning Jura et Ain | Sources & Nature - Guide Professionnel",
|
||||
description: "Guide de canyoning dans le Jura (Saint-Claude) et l'Ain (Ambérieu). Sorties tous niveaux : Grosdar, Coiserette, Chaley, Rhéby. 20 ans d'expérience. Matériel fourni.",
|
||||
siteName: "Sources & Nature - Canyoning Jura Ain",
|
||||
images: [{
|
||||
url: 'https://www.sourcesetnature.fr/images/hero.jpg',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Canyoning dans le Jura et l'Ain avec Sources & Nature - Guide professionnel"
|
||||
}],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: "Canyoning Jura et Ain | Sources & Nature",
|
||||
description: "Guide professionnel de canyoning dans le Jura et l'Ain. Découvrez les plus beaux canyons : Grosdar, Coiserette, Chaley, Rhéby. Sorties adaptées tous niveaux.",
|
||||
images: ['https://www.sourcesetnature.fr/images/hero.jpg'],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
verification: {
|
||||
google: 'SxinCnlsdHG7w5XKGuLP35ouzSeyal002cwt0cLSUGQ'
|
||||
},
|
||||
};
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'TouristAttraction',
|
||||
'@id': 'https://www.sourcesetnature.fr',
|
||||
'name': 'Sources & Nature - Canyoning Jura et Ain',
|
||||
'alternateName': 'Canyoning Sources et Nature',
|
||||
'description': 'Guide professionnel de canyoning dans le Jura et l\'Ain. Stéphane, passionné depuis plus de 20 ans, vous accompagne dans les plus beaux canyons du Jura (Saint-Claude, Région des Lacs) et de l\'Ain (Ambérieu-en-Bugey). Sorties adaptées tous niveaux.',
|
||||
'url': 'https://www.sourcesetnature.fr/',
|
||||
'image': [
|
||||
'https://www.sourcesetnature.fr/images/hero.jpg',
|
||||
'https://www.sourcesetnature.fr/gallery/1.webp',
|
||||
'https://www.sourcesetnature.fr/gallery/2.webp'
|
||||
],
|
||||
'telephone': '+33 6 13 22 36 16',
|
||||
'email': 'contact@sourcesetnature.fr',
|
||||
'address': {
|
||||
'@type': 'PostalAddress',
|
||||
'addressRegion': 'Bourgogne-Franche-Comté et Auvergne-Rhône-Alpes',
|
||||
'addressCountry': 'FR'
|
||||
},
|
||||
'geo': {
|
||||
'@type': 'GeoCoordinates',
|
||||
'latitude': 46.3833,
|
||||
'longitude': 5.8667,
|
||||
'name': 'Zone d\'activité principale : Jura et Ain'
|
||||
},
|
||||
'openingHoursSpecification': {
|
||||
'@type': 'OpeningHoursSpecification',
|
||||
'dayOfWeek': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
|
||||
'opens': '09:00',
|
||||
'closes': '18:00',
|
||||
'validFrom': '2024-04-01',
|
||||
'validThrough': '2024-10-31'
|
||||
},
|
||||
'areaServed': [
|
||||
{
|
||||
'@type': 'AdministrativeArea',
|
||||
'name': 'Jura (39)',
|
||||
'containsPlace': [
|
||||
{
|
||||
'@type': 'City',
|
||||
'name': 'Saint-Claude',
|
||||
'description': 'Canyons de Grosdar et Coiserette'
|
||||
},
|
||||
{
|
||||
'@type': 'Place',
|
||||
'name': 'Région des Lacs du Jura',
|
||||
'description': 'Canyons de Malvaux et Langouette'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'@type': 'AdministrativeArea',
|
||||
'name': 'Ain (01)',
|
||||
'containsPlace': [
|
||||
{
|
||||
'@type': 'City',
|
||||
'name': 'Ambérieu-en-Bugey',
|
||||
'description': 'Canyons de Chaley et Rhéby'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
'touristType': ['Canyoning', 'Sports d\'eau vive', 'Activités de pleine nature'],
|
||||
'availableLanguage': ['French'],
|
||||
'publicAccess': true,
|
||||
'isAccessibleForFree': false,
|
||||
'currenciesAccepted': 'EUR',
|
||||
'paymentAccepted': ['Cash', 'Check', 'Bank Transfer'],
|
||||
'priceRange': '50€ - 85€',
|
||||
'aggregateRating': {
|
||||
'@type': 'AggregateRating',
|
||||
'ratingValue': '5',
|
||||
'reviewCount': '50',
|
||||
'bestRating': '5'
|
||||
},
|
||||
'review': [
|
||||
{
|
||||
'@type': 'Review',
|
||||
'reviewRating': {
|
||||
'@type': 'Rating',
|
||||
'ratingValue': '5'
|
||||
},
|
||||
'author': {
|
||||
'@type': 'Person',
|
||||
'name': 'Client satisfait'
|
||||
},
|
||||
'reviewBody': 'Superbe expérience de canyoning dans le Jura avec Stéphane. Guide très professionnel et passionné!'
|
||||
}
|
||||
],
|
||||
'offers': [
|
||||
{
|
||||
'@type': 'Offer',
|
||||
'name': 'Demi-journée canyoning Jura/Ain',
|
||||
'price': '50',
|
||||
'priceCurrency': 'EUR',
|
||||
'availability': 'https://schema.org/InStock',
|
||||
'description': 'Sortie canyoning d\'une demi-journée dans le Jura ou l\'Ain. Matériel complet fourni.',
|
||||
'validFrom': '2024-04-01',
|
||||
'validThrough': '2024-10-31'
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
'name': 'Journée complète canyoning Jura/Ain',
|
||||
'price': '85',
|
||||
'priceCurrency': 'EUR',
|
||||
'availability': 'https://schema.org/InStock',
|
||||
'description': 'Sortie canyoning d\'une journée complète dans les plus beaux canyons du Jura et de l\'Ain.',
|
||||
'validFrom': '2024-04-01',
|
||||
'validThrough': '2024-10-31'
|
||||
}
|
||||
],
|
||||
'hasOfferCatalog': {
|
||||
'@type': 'OfferCatalog',
|
||||
'name': 'Canyons du Jura et de l\'Ain',
|
||||
'itemListElement': [
|
||||
{
|
||||
'@type': 'Offer',
|
||||
'itemOffered': {
|
||||
'@type': 'Service',
|
||||
'name': 'Sorties initiation Jura',
|
||||
'description': 'Canyon de Grosdar à Saint-Claude (Jura) et canyon de Malvaux dans la Région des Lacs (Jura). Parfait pour découvrir le canyoning.',
|
||||
'areaServed': 'Jura'
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
'itemOffered': {
|
||||
'@type': 'Service',
|
||||
'name': 'Sorties initiation Ain',
|
||||
'description': 'Canyon de Chaley à Ambérieu-en-Bugey (Ain). Idéal pour les débutants.',
|
||||
'areaServed': 'Ain'
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
'itemOffered': {
|
||||
'@type': 'Service',
|
||||
'name': 'Sorties Aventure Jura',
|
||||
'description': 'Canyon de Coiserette à Saint-Claude (Jura) et canyon de Langouette dans la Région des Lacs (Jura). Pour les plus sportifs.',
|
||||
'areaServed': 'Jura'
|
||||
}
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
'itemOffered': {
|
||||
'@type': 'Service',
|
||||
'name': 'Sorties Aventure Ain',
|
||||
'description': 'Canyon de Rhéby à Ambérieu-en-Bugey (Ain). Sensations fortes garanties.',
|
||||
'areaServed': 'Ain'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
'knowsAbout': ['Canyoning', 'Écologie', 'Faune et flore du Jura', 'Faune et flore de l\'Ain', 'Sécurité en montagne'],
|
||||
'memberOf': {
|
||||
'@type': 'Organization',
|
||||
'name': 'Professionnels du canyoning en France'
|
||||
},
|
||||
'sameAs': [
|
||||
'https://www.instagram.com/stef_sources.et.nature/',
|
||||
'https://g.co/kgs/RDrfKL2'
|
||||
]
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta name="google-site-verification" content="SxinCnlsdHG7w5XKGuLP35ouzSeyal002cwt0cLSUGQ" />
|
||||
{/* Préchargement de l'image hero pour un chargement instantané */}
|
||||
<link rel="preload" as="image" href="/images/hero-mobile.webp" type="image/webp" media="(max-width: 768px)" />
|
||||
<link rel="preload" as="image" href="/images/hero.webp" type="image/webp" media="(min-width: 769px)" />
|
||||
<link rel="preload" as="image" href="/images/hero.jpg" type="image/jpeg" />
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${poppins.variable} ${hardcoreAttitude.variable} antialiased`}
|
||||
>
|
||||
<Providers attribute="class" defaultTheme="dark" enableSystem>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
27
src/app/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import Activities from "@/components/activities/activities";
|
||||
import Footer from "@/components/footer";
|
||||
import Gallery from "@/components/gallery/gallery";
|
||||
import { Hero } from "@/components/hero";
|
||||
import Nature from "@/components/nature/nature";
|
||||
import Bubble from "@/components/parallax/bubble";
|
||||
import Speed from "@/components/parallax/speed";
|
||||
import Profile from "@/components/profile/profile";
|
||||
import Tarification from "@/components/tarification/tarification";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<section>
|
||||
<Hero />
|
||||
<Speed />
|
||||
<Profile />
|
||||
<Activities />
|
||||
<Bubble />
|
||||
<Nature />
|
||||
<Tarification />
|
||||
<Gallery />
|
||||
<Footer />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
91
src/components/activities/activities.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { WaveSvg } from "@/components/svgs";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface Activity {
|
||||
title: string;
|
||||
description?: string;
|
||||
image: string;
|
||||
blob: string;
|
||||
}
|
||||
|
||||
// Improved image component with error handling and optimal SEO attributes
|
||||
export function ImageContainer({ activity, id }: { activity: Activity, id: string }) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
return (
|
||||
<article>
|
||||
<div className="w-full flex justify-center">
|
||||
<div className="relative w-64 h-64 overflow-hidden rounded-full shadow-lg">
|
||||
<img
|
||||
src={activity.image}
|
||||
alt={`${activity.title} - Activité canyoning dans l'Ain et le Jura - Sources et Nature`}
|
||||
className="w-full h-full object-cover rounded-full scale-110"
|
||||
onError={() => setImageError(true)}
|
||||
loading="lazy"
|
||||
width="256"
|
||||
height="256"
|
||||
fetchPriority="high"
|
||||
style={{
|
||||
opacity: imageError ? 0.3 : 1,
|
||||
objectPosition: 'center center'
|
||||
}}
|
||||
/>
|
||||
{imageError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-200 bg-opacity-50 rounded-full">
|
||||
<span className="text-gray-700">Image non disponible</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-center text-background font-bold text-3xl uppercase mt-4">{activity.title}</h2>
|
||||
<p className="text-center text-background opacity-90 pt-2">{activity.description}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Activities() {
|
||||
const activitiesData: Activity[] = [
|
||||
{
|
||||
title: "Toboggans",
|
||||
description: "Glissez dans les toboggans naturels sculptés par les torrents du Jura depuis des millénaires.",
|
||||
image: "/activities/slide.webp",
|
||||
blob: "M395.078 119.728C367.548 -44.62 166.708 30.0988 84.5066 79.9847C-35.8505 150.777 31.0878 407.866 111.44 407.866C191.397 407.866 236.847 447.61 266.305 435.811C295.763 424.012 357.625 415.939 357.625 325.275C357.625 234.61 413.595 230.264 395.078 119.728Z"
|
||||
},
|
||||
{
|
||||
title: "Sauts",
|
||||
description: "Sensations fortes garanties dans les vasques cristallines de l'Ain, de 3 à 10 mètres (optionnels).",
|
||||
image: "/activities/jump.webp",
|
||||
blob: "M109.853 23.1412C-39.1979 53.4974 28.5662 274.949 73.8088 365.587C138.012 498.297 371.171 424.489 371.171 335.89C371.171 247.726 407.216 197.612 396.515 165.131C385.815 132.65 378.493 64.4388 296.267 64.4388C214.042 64.4388 210.101 2.7243 109.853 23.1412Z"
|
||||
},
|
||||
{
|
||||
title: "Descente en rappel",
|
||||
description: "Descendez les cascades spectaculaires du massif du Jura en toute sécurité avec nos cordes.",
|
||||
image: "/activities/down.webp",
|
||||
blob: "M306.815 433.859C455.865 403.503 388.101 182.051 342.859 91.4127C278.655 -41.2968 45.4961 32.5114 45.4961 121.11C45.4961 209.274 9.4516 259.388 20.1522 291.869C30.8527 324.35 38.1747 392.561 120.4 392.561C202.625 392.561 206.567 454.276 306.815 433.859Z"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="relative pt-10 w-full min-h-[600px]">
|
||||
<WaveSvg className="fill-secondary -mb-2" />
|
||||
<div className="bg-secondary py-16 px-4">
|
||||
<div className="pb-20">
|
||||
<h1 className="main-title text-background">AVENTURES AQUATIQUES <br />
|
||||
JURA & AIN</h1>
|
||||
</div>
|
||||
<div className="container mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{activitiesData.map((activity, index) => (
|
||||
<div
|
||||
key={`${activity.title}-${index}`}
|
||||
className={`${index === 3 ? "lg:col-start-2" : ""} z-10`}
|
||||
>
|
||||
<ImageContainer id={index.toString()} activity={activity} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
107
src/components/footer.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ReactNode } from "react";
|
||||
import { InstagramIcon, Whatsapp, XIcon } from "./icons";
|
||||
import { Mail, Phone } from "lucide-react";
|
||||
import OptimizedImage from "./global/optimized-image";
|
||||
|
||||
const iconClasses = "w-8 h-auto fill-background hover:fill-muted transition-all";
|
||||
const contactIconClasses = "w-8 h-auto stroke-background hover:stroke-muted transition-all";
|
||||
|
||||
export interface FooterData {
|
||||
title: string;
|
||||
description: string;
|
||||
sigature: boolean;
|
||||
logo: {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
socials: {
|
||||
icon: ReactNode;
|
||||
link: string;
|
||||
}[];
|
||||
contact?: {
|
||||
icon: ReactNode;
|
||||
href: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
const footerData: FooterData = {
|
||||
title: "SOURCES & NATURE",
|
||||
description: "Guide professionnel de canyoning dans l'Ain et le Jura",
|
||||
sigature: true,
|
||||
logo: {
|
||||
src: "/images/logo.png",
|
||||
alt: "Logo Sources et Nature - Guide de canyoning dans l'Ain et le Jura"
|
||||
},
|
||||
socials: [
|
||||
{
|
||||
icon: <Whatsapp className={iconClasses} />,
|
||||
link: "https://wa.me/+33613223616"
|
||||
},
|
||||
{
|
||||
icon: <InstagramIcon className={iconClasses} />,
|
||||
link: "https://www.instagram.com/stef_sources.et.nature/"
|
||||
},
|
||||
],
|
||||
contact: [
|
||||
{
|
||||
icon: <Phone className={contactIconClasses} />,
|
||||
href: "tel:+33613223616"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export default function Footer() {
|
||||
const titleStyle = "text-xl font-bold text-background text-center";
|
||||
const year = new Date().getFullYear();
|
||||
return (
|
||||
<footer className="w-full section-padding" id="contact">
|
||||
<article className="w-[90%] mx-auto py-12 flex-row bg-primary rounded-lg">
|
||||
<div className="w-full flex items-center justify-center">
|
||||
<OptimizedImage
|
||||
src={footerData.logo.src}
|
||||
alt={footerData.logo.alt}
|
||||
containerClassName="w-24 h-24 rounded-lg bg-background relative"
|
||||
className="rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-row pt-4">
|
||||
<h1 className={titleStyle}>{footerData.title}</h1>
|
||||
{/* <p className={titleStyle}>{footerData.description}</p> */}
|
||||
</div>
|
||||
<Link href="https://g.co/kgs/RDrfKL2" target="_blank">
|
||||
<p className={`${titleStyle}`}>✨ Avis Google</p>
|
||||
</Link>
|
||||
<p className={`${titleStyle} pt-2`}>
|
||||
+33 6 13 22 36 16
|
||||
</p>
|
||||
<div className="flex justify-center gap-6 pt-6">
|
||||
{footerData.socials.map((social, index) => (
|
||||
<Link href={social.link} key={index} target="_blank">
|
||||
{social.icon}
|
||||
</Link>
|
||||
))}
|
||||
{footerData.contact && (
|
||||
<>
|
||||
{footerData.contact.map((contact, index) => (
|
||||
<Link href={contact.href} key={index}>
|
||||
{contact.icon}
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-background text-center pt-4">Copyright © {year} - All right reserved</p>
|
||||
{footerData.sigature && (
|
||||
<p className="text-background opacity-70 text-center">Made with ❤️ by
|
||||
<Link href="https://itskody.fr" target="_blank" className="font-bold"> KODY.</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
154
src/components/gallery/gallery.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
export interface GalleryImage {
|
||||
id: number;
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
const imagePath = "/gallery/";
|
||||
|
||||
const galleryData: GalleryImage[] = [
|
||||
{ id: 1, src: imagePath + '1.webp', alt: 'Canyon de Grosdar - Toboggan naturel dans le Jura' },
|
||||
{ id: 2, src: imagePath + '2.webp', alt: 'Descente en rappel au canyon de Coiserette - Saint-Claude Jura' },
|
||||
{ id: 3, src: imagePath + '3.webp', alt: 'Saut dans les vasques du canyon de Chaley - Ain' },
|
||||
{ id: 4, src: imagePath + '4.webp', alt: 'Canyon de Rhéby - Aventure canyoning dans l\'Ain' },
|
||||
{ id: 5, src: imagePath + '5.webp', alt: 'Toboggan aquatique naturel - Région des Lacs Jura' },
|
||||
{ id: 6, src: imagePath + '6.webp', alt: 'Canyon de Malvaux - Initiation canyoning Jura' },
|
||||
{ id: 7, src: imagePath + '7.webp', alt: 'Descente en rappel cascade - Massif du Jura' },
|
||||
{ id: 8, src: imagePath + '8.webp', alt: 'Canyon de Langouette - Parcours aventure Jura' },
|
||||
{ id: 9, src: imagePath + '9.webp', alt: 'Piscine naturelle - Gorges de l\'Ain' },
|
||||
{ id: 10, src: imagePath + '10.webp', alt: 'Groupe canyoning - Saint-Claude Jura' },
|
||||
{ id: 11, src: imagePath + '11.webp', alt: 'Vue panoramique - Canyons du Jura et de l\'Ain' },
|
||||
];
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ImageModal } from '@/components/global/fullscreen-image';
|
||||
import OptimizedImage from '../global/optimized-image';
|
||||
|
||||
export interface GalleryImage {
|
||||
id: number;
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
export default function Gallery() {
|
||||
const [selectedImage, setSelectedImage] = useState<GalleryImage | null>(null);
|
||||
const [showFullGallery, setShowFullGallery] = useState(false);
|
||||
|
||||
const previewImages = galleryData.slice(0, 3);
|
||||
const remainingImagesCount = galleryData.length - 3;
|
||||
|
||||
const getCellSize = (index: number, totalImages: number) => {
|
||||
let className = "";
|
||||
|
||||
if (index === 0) {
|
||||
className = "col-span-2 row-span-2";
|
||||
}
|
||||
else if (totalImages >= 5) {
|
||||
switch (index) {
|
||||
case Math.floor(totalImages / 2):
|
||||
className = "row-span-2";
|
||||
break;
|
||||
case totalImages - 2:
|
||||
className = "col-span-2";
|
||||
break;
|
||||
default:
|
||||
className = "";
|
||||
}
|
||||
}
|
||||
|
||||
return className;
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (!selectedImage) return;
|
||||
const currentIndex = galleryData.findIndex(img => img.id === selectedImage.id);
|
||||
const nextIndex = (currentIndex + 1) % galleryData.length;
|
||||
setSelectedImage(galleryData[nextIndex]);
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (!selectedImage) return;
|
||||
const currentIndex = galleryData.findIndex(img => img.id === selectedImage.id);
|
||||
const previousIndex = (currentIndex - 1 + galleryData.length) % galleryData.length;
|
||||
setSelectedImage(galleryData[previousIndex]);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="section-padding max-w-7xl mx-auto">
|
||||
<div>
|
||||
<h1 className="main-title">
|
||||
Galerie Photos<br />Canyoning Jura & Ain
|
||||
</h1>
|
||||
<div className="flex justify-center">
|
||||
<p className="main-description">
|
||||
Découvrez en images nos sorties canyoning à Saint-Claude, Ambérieu-en-Bugey et dans la Région des Lacs.
|
||||
Toboggans, sauts et rappels dans les plus beaux canyons du Jura et de l'Ain !
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-8">
|
||||
{/* Preview des 3 premières images */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
{previewImages.map((image, index) => (
|
||||
<OptimizedImage
|
||||
key={image.id}
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
containerClassName="relative overflow-hidden rounded-2xl shadow-lg h-[320px]"
|
||||
className="object-cover w-full h-full transition-transform duration-300 hover:scale-105 hover:cursor-pointer rounded-2xl"
|
||||
onClick={() => setSelectedImage(image)}
|
||||
style={{ aspectRatio: '1080/920' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bouton pour afficher le reste */}
|
||||
{!showFullGallery ? (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-lg text-muted-foreground mb-2">
|
||||
Et encore {remainingImagesCount} photos à découvrir ! 🎉
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cliquez pour voir toute la collection
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowFullGallery(true)}
|
||||
className="px-8 py-4 bg-primary text-primary-foreground rounded-xl font-semibold text-lg hover:bg-primary/90 transition-all duration-300 transform hover:scale-105 shadow-lg"
|
||||
>
|
||||
📸 Découvrir les {remainingImagesCount} autres photos
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
/* Galerie complète */
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 auto-rows-[320px]">
|
||||
{galleryData.map((image, index) => (
|
||||
<OptimizedImage
|
||||
key={image.id}
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
containerClassName={`relative overflow-hidden ${getCellSize(index, galleryData.length)} rounded-2xl shadow-lg`}
|
||||
className="object-cover w-full h-full transition-transform duration-300 hover:scale-105 hover:cursor-pointer rounded-2xl"
|
||||
onClick={() => setSelectedImage(image)}
|
||||
style={{ aspectRatio: '1080/920' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ImageModal
|
||||
image={{
|
||||
src: selectedImage?.src || '',
|
||||
alt: selectedImage?.alt || '',
|
||||
}}
|
||||
isOpen={!!selectedImage}
|
||||
onClose={() => setSelectedImage(null)}
|
||||
onNext={handleNext}
|
||||
onPrevious={handlePrevious}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
45
src/components/global/button.theme.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Laptop, Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function ButtonTheme({ withText }: { withText?: boolean }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [currentTheme, setCurrentTheme] = React.useState<
|
||||
"system" | "light" | "dark"
|
||||
>("system");
|
||||
|
||||
React.useEffect(() => {
|
||||
setCurrentTheme(theme as "system" | "light" | "dark");
|
||||
}, [theme]);
|
||||
|
||||
const cycleTheme = () => {
|
||||
const themes: ("system" | "light" | "dark")[] = ["system", "light", "dark"];
|
||||
const currentIndex = themes.indexOf(currentTheme);
|
||||
const nextIndex = (currentIndex + 1) % themes.length;
|
||||
setTheme(themes[nextIndex]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size={withText ? "default" : "icon"}
|
||||
onClick={cycleTheme}
|
||||
>
|
||||
{currentTheme === "system" && (
|
||||
<Laptop className="h-[1.2rem] w-[1.2rem]" />
|
||||
)}
|
||||
{currentTheme === "light" && <Sun className="h-[1.2rem] w-[1.2rem]" />}
|
||||
{currentTheme === "dark" && <Moon className="h-[1.2rem] w-[1.2rem]" />}
|
||||
{withText && (
|
||||
<span className="ml-2 capitalize">
|
||||
{currentTheme === "system" ? "System" : currentTheme}
|
||||
</span>
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
39
src/components/global/dialog.confirmation.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export function DialogConfirmation({
|
||||
trigger,
|
||||
title,
|
||||
description,
|
||||
labelConfirmButton,
|
||||
onConfirm,
|
||||
}: {
|
||||
trigger: JSX.Element;
|
||||
title: string;
|
||||
description: string;
|
||||
labelConfirmButton: string;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="md:text-left">{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={onConfirm}>{labelConfirmButton}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
13
src/components/global/form-fields/button.submit.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function ButtonSubmit({ label, disabled, loading }: { label: string, disabled?: boolean, loading?: boolean }) {
|
||||
return (
|
||||
<Button type="submit" disabled={disabled || loading} className="w-full">
|
||||
{loading && <ReloadIcon className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
54
src/components/global/form-fields/input.form-field.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Control, Path } from "react-hook-form";
|
||||
|
||||
const InputField = <TFieldValues extends Record<string, string>>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
...props
|
||||
}: {
|
||||
control: Control<TFieldValues>;
|
||||
name: Path<TFieldValues>;
|
||||
label: string;
|
||||
description?: string;
|
||||
} & React.ComponentProps<typeof Input>) => {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => {
|
||||
if (!field) {
|
||||
console.error("Field is missing for InputField", name);
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormItem className="space-y-1.5">
|
||||
<FormLabel className="flex items-center justify-between">
|
||||
{label}
|
||||
<FormMessage className="max-sm:hidden text-sm" />
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} {...props} />
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage className="sm:hidden text-xs text-left" />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputField;
|
||||
71
src/components/global/form-fields/select.form-field.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { SelectOption } from "@/types/components";
|
||||
import { Control, Path, PathValue } from "react-hook-form";
|
||||
|
||||
const SelectField = <
|
||||
TFieldValues extends Record<string, string>,
|
||||
UFieldDefaultValues extends PathValue<TFieldValues, Path<TFieldValues>>
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
values,
|
||||
defaultValues,
|
||||
label,
|
||||
placeholder,
|
||||
description,
|
||||
}: {
|
||||
control: Control<TFieldValues>;
|
||||
name: Path<TFieldValues>;
|
||||
values: SelectOption[];
|
||||
defaultValues?: UFieldDefaultValues;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
defaultValue={defaultValues}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{values.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectField;
|
||||
54
src/components/global/form-fields/textarea.form-field.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { Control, Path } from "react-hook-form";
|
||||
|
||||
const TextareaField = <TFieldValues extends Record<string, string>>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
description,
|
||||
...props
|
||||
}: {
|
||||
control: Control<TFieldValues>;
|
||||
name: Path<TFieldValues>;
|
||||
label: string;
|
||||
description?: string;
|
||||
} & React.ComponentProps<typeof Textarea>) => {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => {
|
||||
if (!field) {
|
||||
console.error("Field is missing for InputField", name);
|
||||
return <></>;
|
||||
}
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="resize-none"
|
||||
{...field}
|
||||
{...props}
|
||||
/>
|
||||
</FormControl>
|
||||
{description && <FormDescription>{description}</FormDescription>}
|
||||
<FormMessage className="sm:hidden text-xs text-left"/>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
};
|
||||
|
||||
export default TextareaField;
|
||||
53
src/components/global/fullscreen-image.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ModalProps {
|
||||
image: {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
}
|
||||
|
||||
export function ImageModal({ image, isOpen, onClose, onNext, onPrevious }: ModalProps) {
|
||||
if (!image) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => onClose()}>
|
||||
<DialogContent className="max-w-[90vw] max-h-[90vh] p-0 bg-foreground/90">
|
||||
<Button size={"icon"} variant={"ghost"} className="absolute right-4 top-4 z-50" onClick={onClose}>
|
||||
<X className="h-10 w-10 text-background" />
|
||||
</Button>
|
||||
<div className="relative flex items-center justify-center w-full h-full">
|
||||
<img
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
className="max-h-[85vh] max-w-[85vw] object-contain"
|
||||
/>
|
||||
{onPrevious && (
|
||||
<Button
|
||||
size={"icon"} variant={"ghost"}
|
||||
onClick={onPrevious}
|
||||
className="absolute left-4"
|
||||
>
|
||||
<ChevronLeft className="h-8 w-8 text-background" />
|
||||
</Button>
|
||||
)}
|
||||
{onNext && (
|
||||
<Button
|
||||
size={"icon"} variant={"ghost"}
|
||||
onClick={onNext}
|
||||
className="absolute right-4"
|
||||
>
|
||||
<ChevronRight className="h-8 w-8 text-background" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
22
src/components/global/hover.item.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
|
||||
export default function HoverItem({
|
||||
trigger,
|
||||
children
|
||||
}: {
|
||||
trigger: string | JSX.Element;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger className="cursor-pointer">{trigger}</HoverCardTrigger>
|
||||
<HoverCardContent className="text-sm w-full">
|
||||
{children}
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
53
src/components/global/optimized-image.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import Image, { ImageProps } from 'next/image';
|
||||
|
||||
interface OptimizedImageProps extends ImageProps {
|
||||
skeletonClassName?: string;
|
||||
onImageLoad?: () => void;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
export default function OptimizedImage({
|
||||
skeletonClassName = '',
|
||||
onImageLoad,
|
||||
containerClassName = '',
|
||||
className = '',
|
||||
...imageProps
|
||||
}: OptimizedImageProps) {
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setImageLoaded(true);
|
||||
if (onImageLoad) {
|
||||
onImageLoad();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${containerClassName}`}>
|
||||
{!imageLoaded && (
|
||||
<Skeleton
|
||||
className={`absolute inset-0 w-full h-full ${skeletonClassName} rounded-radius`}
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
{...imageProps}
|
||||
className={`${className} ${imageLoaded ? 'relative z-10' : 'invisible'}`}
|
||||
fill={true}
|
||||
style={{
|
||||
objectFit: 'cover',
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
alt={imageProps.alt || ''}
|
||||
onLoad={(event) => {
|
||||
handleImageLoad();
|
||||
if (imageProps.onLoad) {
|
||||
imageProps.onLoad(event);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
src/components/hero.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
interface HeroButton {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface HeroDatas {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
video?: string;
|
||||
poster?: string;
|
||||
buttons: [HeroButton, HeroButton];
|
||||
wave: boolean;
|
||||
}
|
||||
|
||||
const heroData: HeroDatas = {
|
||||
title: <span>Canyoning Jura & Ain<br />Saint-Claude • Ambérieu</span>,
|
||||
description: <>Guide professionnel depuis plus de 20 ans dans les plus beaux canyons du Jura (Grosdar, Coiserette) et de l'Ain (Chaley, Rhéby). Sorties adaptées à tous niveaux.</>,
|
||||
video: undefined,
|
||||
poster: "/images/hero.jpg",
|
||||
buttons: [
|
||||
{
|
||||
label: "LES SORTIES",
|
||||
href: "#sorties"
|
||||
},
|
||||
{
|
||||
label: "ME CONTACTER",
|
||||
href: "#contact"
|
||||
}
|
||||
],
|
||||
wave: true
|
||||
};
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="relative w-full h-screen z-20">
|
||||
<div className="absolute inset-0 -z-10">
|
||||
{heroData.video ? (
|
||||
<VideoBackground
|
||||
src={heroData.video}
|
||||
poster={heroData.poster}
|
||||
/>
|
||||
) : heroData.poster ? (
|
||||
<BackgroundImage
|
||||
src={heroData.poster}
|
||||
alt="Background landscape"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-foreground opacity-40" />
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto h-full flex items-center justify-center">
|
||||
<HeroContent data={heroData} />
|
||||
</div>
|
||||
|
||||
{heroData.wave && <WaveSeparator />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroContent({ data }: { data: HeroDatas }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<h1 className="font-hardcore text-background text-6xl md:text-6xl lg:text-8xl mb-6">
|
||||
{data.title}
|
||||
</h1>
|
||||
|
||||
<p className="text-background max-w-xl mx-auto mb-8">
|
||||
{data.description}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-center space-x-4">
|
||||
{data.buttons.map((button, index) => (
|
||||
<Link key={index} href={button.href}>
|
||||
<Button variant="default" className="uppercase">
|
||||
{button.label}
|
||||
</Button>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoBackground({
|
||||
src,
|
||||
poster
|
||||
}: {
|
||||
src: string;
|
||||
poster?: string;
|
||||
}) {
|
||||
return (
|
||||
<video
|
||||
className="w-full h-full object-cover"
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
poster={poster}
|
||||
>
|
||||
<source src={src} type="video/webm" />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
|
||||
function BackgroundImage({
|
||||
src,
|
||||
alt
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
{/* Version optimisée : Mobile 179KB, Desktop 662KB vs 2.18MB original */}
|
||||
<picture>
|
||||
{/* Mobile WebP ultra-optimisé (92% plus petite) */}
|
||||
<source
|
||||
srcSet="/images/hero-mobile.webp"
|
||||
type="image/webp"
|
||||
media="(max-width: 768px)"
|
||||
/>
|
||||
{/* Desktop WebP optimisé (69% plus petite) */}
|
||||
<source
|
||||
srcSet="/images/hero.webp"
|
||||
type="image/webp"
|
||||
media="(min-width: 769px)"
|
||||
/>
|
||||
{/* Fallback JPG pour compatibilité */}
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
priority
|
||||
sizes="(max-width: 768px) 768px, 1920px"
|
||||
className="object-cover"
|
||||
quality={85}
|
||||
placeholder="blur"
|
||||
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAhEAACAQMDBQAAAAAAAAAAAAABAgMABAUGIWGRkqGx0f/EABUBAQEAAAAAAAAAAAAAAAAAAAMF/8QAGhEAAgIDAAAAAAAAAAAAAAAAAAECEgMRkf/aAAwDAQACEQMRAD8AltJagyeH0AthI5xdrLcNM91BF5pX2HaH9bcfaSXWGaRmknyLDyeD8q0QQ7Bqn1p1c1b+1OvM7jFJ/Q9m9p8V"
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WaveSeparator() {
|
||||
return (
|
||||
<div className="w-full absolute -bottom-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 120 1440 200"
|
||||
style={{ transform: "scaleX(-1)" }}
|
||||
>
|
||||
<path
|
||||
className="fill-background"
|
||||
fillOpacity="1"
|
||||
d="M0,128L120,160C240,192,480,256,720,282.7C960,309,1200,299,1320,293.3L1440,288L1440,320L1320,320C1200,320,960,320,720,320C480,320,240,320,120,320L0,320Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/components/icons.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { IconProps } from "@/types/components";
|
||||
|
||||
//https://simpleicons.org/
|
||||
|
||||
export function LinkedInIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg role="img" viewBox="0 0 24 24" className={className} xmlns="http://www.w3.org/2000/svg">
|
||||
<title>LinkedIn</title>
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function GitHubIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg role="img" viewBox="0 0 24 24" className={className} xmlns="http://www.w3.org/2000/svg">
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function XIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg role="img" viewBox="0 0 24 24" className={className} xmlns="http://www.w3.org/2000/svg">
|
||||
<title>X</title>
|
||||
<path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function FacebookIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg role="img" viewBox="0 0 24 24" className={className} xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Facebook</title>
|
||||
<path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function InstagramIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg role="img" viewBox="0 0 24 24" className={className} xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Instagram</title>
|
||||
<path d="M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Whatsapp({ className }: IconProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" className={className}>
|
||||
<path d="M25,2C12.318,2,2,12.318,2,25c0,3.96,1.023,7.854,2.963,11.29L2.037,46.73c-0.096,0.343-0.003,0.711,0.245,0.966 C2.473,47.893,2.733,48,3,48c0.08,0,0.161-0.01,0.24-0.029l10.896-2.699C17.463,47.058,21.21,48,25,48c12.682,0,23-10.318,23-23 S37.682,2,25,2z M36.57,33.116c-0.492,1.362-2.852,2.605-3.986,2.772c-1.018,0.149-2.306,0.213-3.72-0.231 c-0.857-0.27-1.957-0.628-3.366-1.229c-5.923-2.526-9.791-8.415-10.087-8.804C15.116,25.235,13,22.463,13,19.594 s1.525-4.28,2.067-4.864c0.542-0.584,1.181-0.73,1.575-0.73s0.787,0.005,1.132,0.021c0.363,0.018,0.85-0.137,1.329,1.001 c0.492,1.168,1.673,4.037,1.819,4.33c0.148,0.292,0.246,0.633,0.05,1.022c-0.196,0.389-0.294,0.632-0.59,0.973 s-0.62,0.76-0.886,1.022c-0.296,0.291-0.603,0.606-0.259,1.19c0.344,0.584,1.529,2.493,3.285,4.039 c2.255,1.986,4.158,2.602,4.748,2.894c0.59,0.292,0.935,0.243,1.279-0.146c0.344-0.39,1.476-1.703,1.869-2.286 s0.787-0.487,1.329-0.292c0.542,0.194,3.445,1.604,4.035,1.896c0.59,0.292,0.984,0.438,1.132,0.681 C37.062,30.587,37.062,31.755,36.57,33.116z"></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
97
src/components/nature/nature.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import Image from "next/image";
|
||||
import { WaveSvg } from "../svgs";
|
||||
import { ImageModal } from "@/components/global/fullscreen-image";
|
||||
import { useState } from "react";
|
||||
import OptimizedImage from "../global/optimized-image";
|
||||
|
||||
interface NatureCard {
|
||||
title: string;
|
||||
description: string;
|
||||
images: ImageData[];
|
||||
}
|
||||
|
||||
interface ImageData {
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
export default function Nature() {
|
||||
const [selectedImage, setSelectedImage] = useState<ImageData | null>(null);
|
||||
|
||||
const natureData: NatureCard[] = [
|
||||
{
|
||||
title: "Faune",
|
||||
description: "Découvrez la faune exceptionnelle des gorges du Jura et de l'Ain : chamois, lynx, faucons pèlerins et salamandres tachetées.",
|
||||
images: [
|
||||
{ src: "/nature/animals/1.webp", alt: "Faune des canyons du Jura - Chamois et bouquetins" },
|
||||
{ src: "/nature/animals/2.webp", alt: "Oiseaux des gorges de l'Ain - Faucon pèlerin" },
|
||||
{ src: "/nature/animals/3.webp", alt: "Amphibiens des rivières du Jura - Salamandre tachetée" }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Flore",
|
||||
description: "Explorez la flore remarquable des canyons : fougères scolopendres, mousses aquatiques et orchidées sauvages du Jura.",
|
||||
images: [
|
||||
{ src: "/nature/plants/1.webp", alt: "Flore des canyons du Jura - Fougères et mousses" },
|
||||
{ src: "/nature/plants/2.webp", alt: "Plantes aquatiques de l'Ain - Végétation de canyon" },
|
||||
{ src: "/nature/plants/3.webp", alt: "Orchidées sauvages du Jura - Flore protégée" }
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="relative w-full min-h-[600px] z-10">
|
||||
<WaveSvg className="fill-accent -mb-4 bg-secondary" />
|
||||
<div className="bg-accent py-16 px-4">
|
||||
<div className="pb-20">
|
||||
<h1 className="main-title text-background">Écosystèmes préservés <br />
|
||||
du Jura et de l'Ain</h1>
|
||||
</div>
|
||||
<div className="container mx-auto">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-32 xl:gap-8">
|
||||
{natureData.map((nature, index) => (
|
||||
<NatureCard key={`${nature.title}-${index}`} data={nature} setSelectedImage={setSelectedImage}/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<WaveSvg className="fill-accent -mt-2 rotate-180" />
|
||||
<ImageModal
|
||||
image={selectedImage as ImageData}
|
||||
isOpen={!!selectedImage}
|
||||
onClose={() => setSelectedImage(null)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function NatureCard({ data, setSelectedImage } : { data: NatureCard, setSelectedImage: (data: ImageData) => void }) {
|
||||
return (
|
||||
<article className="flex flex-col gap-4 items-center">
|
||||
<OptimizedImage
|
||||
src={data.images[0].src}
|
||||
alt={data.images[0].alt}
|
||||
containerClassName="w-[350px] h-[298px] sm:w-[450px] sm:h-[383px] md:w-[700px] md:h-[596px] relative overflow-hidden rounded-2xl shadow-lg"
|
||||
className="object-cover w-full h-full cursor-pointer hover:scale-110 transition-all rounded-2xl"
|
||||
onClick={() => setSelectedImage(data.images[0])}
|
||||
/>
|
||||
|
||||
<h1 className="text-background text-2xl font-bold uppercase">{data.title}</h1>
|
||||
<p className="text-background opacity-90 text-center">{data.description}</p>
|
||||
{data.images.length > 1 && (
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{data.images.slice(1).map((image, index) => (
|
||||
<OptimizedImage
|
||||
key={`${data.title}-${index}`}
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
containerClassName="w-[140px] h-[119px] sm:w-[220px] sm:h-[187px] relative overflow-hidden rounded-2xl shadow-md"
|
||||
className="object-cover w-full h-full cursor-pointer hover:scale-110 transition-all rounded-2xl"
|
||||
onClick={() => setSelectedImage(image)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
69
src/components/parallax/bubble.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Circle } from '@/components/profile/svgs';
|
||||
import { CirclesConfig } from '@/types/waves';
|
||||
|
||||
const bubbles: CirclesConfig[] = [
|
||||
{ id: 1, width: 40, height: 40, left: '10%', initialTop: 10, scrollFactor: 0.5 },
|
||||
{ id: 2, width: 120, height: 120, left: '20%', initialTop: 20, scrollFactor: 0.3 },
|
||||
{ id: 3, width: 80, height: 80, left: '30%', initialTop: 30, scrollFactor: 0.8 },
|
||||
{ id: 4, width: 120, height: 120, left: '40%', initialTop: 40, scrollFactor: 0.2 },
|
||||
{ id: 5, width: 60, height: 60, left: '50%', initialTop: 50, scrollFactor: 0.6 },
|
||||
{ id: 6, width: 100, height: 100, left: '60%', initialTop: 60, scrollFactor: 0.2 },
|
||||
{ id: 7, width: 50, height: 50, left: '70%', initialTop: 70, scrollFactor: 0.5 },
|
||||
{ id: 8, width: 80, height: 80, left: '75%', initialTop: 80, scrollFactor: 0.3 },
|
||||
{ id: 9, width: 40, height: 40, left: '74%', initialTop: 90, scrollFactor: 0.8 },
|
||||
{ id: 10, width: 70, height: 70, left: '10%', initialTop: 100, scrollFactor: 0.2 },
|
||||
{ id: 11, width: 100, height: 100, left: '20%', initialTop: 110, scrollFactor: 1 },
|
||||
{ id: 12, width: 120, height: 120, left: '30%', initialTop: 120, scrollFactor: 0.6 },
|
||||
{ id: 13, width: 60, height: 60, left: '40%', initialTop: 130, scrollFactor: 0.2 },
|
||||
{ id: 14, width: 80, height: 80, left: '50%', initialTop: 140, scrollFactor: 0.8 },
|
||||
{ id: 15, width: 40, height: 40, left: '60%', initialTop: 150, scrollFactor: 0.3 },
|
||||
{ id: 16, width: 110, height: 110, left: '70%', initialTop: 160, scrollFactor: 0.6 },
|
||||
{ id: 17, width: 90, height: 90, left: '70%', initialTop: 170, scrollFactor: 0.2 },
|
||||
{ id: 18, width: 70, height: 70, left: '60%', initialTop: 180, scrollFactor: 0.5 },
|
||||
{ id: 19, width: 50, height: 50, left: '10%', initialTop: 190, scrollFactor: 0.9 },
|
||||
{ id: 20, width: 30, height: 30, left: '35%', initialTop: 200, scrollFactor: 0.3 },
|
||||
];
|
||||
|
||||
export default function Bubble() {
|
||||
const [scrolled, setScrolled] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const calculateRockPosition = (config: typeof bubbles[number]) => {
|
||||
const basePosition = `${config.initialTop}%`;
|
||||
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: config.left,
|
||||
top: `calc(${basePosition} - ${scrolled * config.scrollFactor}px)`,
|
||||
width: `${config.width}px`,
|
||||
height: `${config.height}px`,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className=' relative flex justify-center items-center'>
|
||||
<div className="relative w-full h-full">
|
||||
{bubbles.map((config) => {
|
||||
return (
|
||||
<Circle key={config.id} className='fill-transparent stroke-white stroke-2 z-0' width={config.width} height={config.height} style={calculateRockPosition(config) as React.CSSProperties}/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
79
src/components/parallax/speed.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { Line } from '@/components/profile/svgs';
|
||||
import { LinesConfig } from '@/types/waves';
|
||||
|
||||
const lines: LinesConfig[] = [
|
||||
{ id: 1, width: 100, height: 100, left: '5%', initialTop: 300, scrollFactor: 0.8 },
|
||||
{ id: 2, width: 120, height: 120, left: '15%', initialTop: 350, scrollFactor: 0.6 },
|
||||
{ id: 3, width: 80, height: 80, left: '25%', initialTop: 400, scrollFactor: 0.4, bottom: true },
|
||||
{ id: 4, width: 70, height: 70, left: '35%', initialTop: 450, scrollFactor: 0.7, bottom: true },
|
||||
{ id: 5, width: 120, height: 120, left: '45%', initialTop: 500, scrollFactor: 0.5, bottom: true, flipped: true },
|
||||
{ id: 6, width: 50, height: 50, left: '55%', initialTop: 550, scrollFactor: 0.2, bottom: true, flipped: true },
|
||||
{ id: 7, width: 70, height: 70, left: '65%', initialTop: 600, scrollFactor: 0.4, bottom: true },
|
||||
{ id: 8, width: 90, height: 90, left: '75%', initialTop: 650, scrollFactor: 0.6 },
|
||||
{ id: 9, width: 100, height: 100, left: '35%', initialTop: 700, scrollFactor: 0.8 },
|
||||
{ id: 10, width: 120, height: 120, left: '65%', initialTop: 750, scrollFactor: 0.6 },
|
||||
{ id: 11, width: 80, height: 80, left: '10%', initialTop: 800, scrollFactor: 0.4 },
|
||||
{ id: 12, width: 130, height: 130, left: '20%', initialTop: 850, scrollFactor: 0.5 },
|
||||
{ id: 13, width: 110, height: 110, left: '30%', initialTop: 900, scrollFactor: 0.7, flipped: true },
|
||||
{ id: 14, width: 70, height: 70, left: '40%', initialTop: 300, scrollFactor: 0.7 },
|
||||
{ id: 15, width: 120, height: 120, left: '50%', initialTop: 350, scrollFactor: 0.5, flipped: true },
|
||||
{ id: 16, width: 50, height: 50, left: '60%', initialTop: 400, scrollFactor: 0.2 },
|
||||
{ id: 17, width: 70, height: 70, left: '70%', initialTop: 450, scrollFactor: 0.4 },
|
||||
{ id: 18, width: 90, height: 90, left: '73%', initialTop: 500, scrollFactor: 0.6 },
|
||||
{ id: 19, width: 100, height: 100, left: '68%', initialTop: 550, scrollFactor: 0.8 },
|
||||
{ id: 20, width: 120, height: 120, left: '5%', initialTop: 600, scrollFactor: 0.6 },
|
||||
{ id: 21, width: 80, height: 80, left: '15%', initialTop: 650, scrollFactor: 0.4 },
|
||||
{ id: 22, width: 70, height: 70, left: '25%', initialTop: 700, scrollFactor: 0.7 },
|
||||
{ id: 23, width: 120, height: 120, left: '35%', initialTop: 750, scrollFactor: 0.5, flipped: true },
|
||||
{ id: 24, width: 50, height: 50, left: '45%', initialTop: 800, scrollFactor: 0.2, flipped: true },
|
||||
{ id: 25, width: 70, height: 70, left: '55%', initialTop: 850, scrollFactor: 0.4 },
|
||||
{ id: 26, width: 90, height: 90, left: '65%', initialTop: 900, scrollFactor: 0.6 },
|
||||
{ id: 27, width: 100, height: 100, left: '70%', initialTop: 300, scrollFactor: 0.8 },
|
||||
];
|
||||
|
||||
export default function Speed() {
|
||||
const [scrolled, setScrolled] = useState<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setScrolled(window.scrollY);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const calculateRockPosition = (config: typeof lines[number]) => {
|
||||
const basePosition = config.bottom
|
||||
? `calc(100% - ${config.initialTop}%)`
|
||||
: `${config.initialTop}%`;
|
||||
|
||||
return {
|
||||
position: 'absolute',
|
||||
left: config.left,
|
||||
top: `calc(${basePosition} - ${scrolled * config.scrollFactor}px)`,
|
||||
width: `${config.width}px`,
|
||||
height: `${config.height}px`,
|
||||
transform: config.flipped ? 'scaleX(-1)' : 'none',
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className='h-[20vh] relative flex justify-center items-center'>
|
||||
<div className="relative w-full h-full">
|
||||
{lines.map((config) => {
|
||||
return (
|
||||
<Line key={config.id} className="fill-slate-400" width={config.width} height={config.height} style={calculateRockPosition(config) as React.CSSProperties}/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
84
src/components/profile/profile.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import React from 'react';
|
||||
import OptimizedImage from "../global/optimized-image";
|
||||
|
||||
export interface PolygonProps {
|
||||
size: string;
|
||||
position: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface ImageData {
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
export interface CardData {
|
||||
title: string;
|
||||
description: string;
|
||||
image: ImageData;
|
||||
polygons?: PolygonProps[];
|
||||
}
|
||||
|
||||
const Polygon: React.FC<PolygonProps> = ({ size, position, color }) => {
|
||||
return (
|
||||
<div className={`absolute z-0 rounded-2xl ${color} ${size} ${position}`} />
|
||||
);
|
||||
};
|
||||
|
||||
const cardData: CardData = {
|
||||
title: "QUI SUIS-JE ?",
|
||||
description: "Stéphane, guide de canyoning diplômé d'État basé entre le Jura et l'Ain. Depuis plus de 20 ans, je parcours les canyons d'Europe et connais parfaitement les gorges de Saint-Claude, d'Ambérieu-en-Bugey et de la Région des Lacs. Passionné d'écologie, je vous ferai découvrir la richesse des écosystèmes jurassiens et aindinois : lynx, chamois, faucons pèlerins et une flore exceptionnelle.",
|
||||
image: {
|
||||
src: "/images/stef.jpg",
|
||||
alt: "Stéphane - Guide de canyoning professionnel dans le Jura et l'Ain"
|
||||
},
|
||||
polygons: [
|
||||
{ size: "w-20 h-20", position: "-right-10 -top-10", color: "bg-secondary" },
|
||||
{ size: "w-12 h-12", position: "right-20 -top-20", color: "bg-accent" },
|
||||
{ size: "w-16 h-16", position: "-left-8 bottom-10", color: "bg-primary" }
|
||||
]
|
||||
};
|
||||
|
||||
export default function Profile() {
|
||||
return (
|
||||
<section className="w-full section-padding max-w-6xl mx-auto p-4 sm:p-6 min-h-[calc(100vh-4rem)] flex items-center bg-transparent z-20">
|
||||
<div className="relative w-full">
|
||||
<div className="relative rounded-2xl shadow-xl">
|
||||
<div className="p-4 sm:p-6 md:p-8 lg:p-12 relative z-20 bg-background">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 sm:gap-8 md:gap-12 items-center">
|
||||
<div className="relative flex justify-center items-center">
|
||||
<div className="relative z-20 w-full max-w-[280px] sm:max-w-[400px] md:max-w-[500px] aspect-square">
|
||||
<OptimizedImage
|
||||
src={cardData.image.src}
|
||||
alt={cardData.image.alt}
|
||||
containerClassName="relative w-full h-full z-0"
|
||||
className="object-cover rounded-2xl"
|
||||
priority
|
||||
/>
|
||||
<div className="block">
|
||||
{cardData.polygons?.map((polygon, index) => (
|
||||
<Polygon key={index} {...polygon} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<h1 className="font-hardcore text-center lg:text-left pt-10 md:pt-0 text-3xl sm:text-4xl md:text-6xl lg:text-7xl">
|
||||
{cardData.title}
|
||||
</h1>
|
||||
<div className="w-16 sm:w-24 h-1 bg-secondary rounded-full hidden lg:flex" />
|
||||
<div className="w-full">
|
||||
<p className="text-base sm:text-lg text-foreground leading-relaxed text-center lg:text-left">
|
||||
{cardData.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
48
src/components/profile/svgs.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
interface SvgProps {
|
||||
width?: number;
|
||||
height?: number;
|
||||
fill?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function Line(props: SvgProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className={props.className ? props.className : ""} style={props.style ? props.style : undefined} width={props.width ? props.width : "1000"} height={props.height ? props.height : "1000"} fill={props.fill ? props.fill : "current"} viewBox="0 0 1000 1000">
|
||||
<rect id="line" data-name="line" className="cls-1" x="473" y="36" width="27" height="928" rx="13.5" ry="13.5"/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Wave(props: SvgProps) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 320" className={props.className ? props.className : ""} style={props.style ? props.style : undefined} width={props.width ? props.width : "1000"} height={props.height ? props.height : "1000"} fill={props.fill ? props.fill : "current"}>
|
||||
<path fill-opacity="1" d="M0,96L48,90.7C96,85,192,75,288,101.3C384,128,480,192,576,192C672,192,768,128,864,117.3C960,107,1056,149,1152,170.7C1248,192,1344,192,1392,192L1440,192L1440,320L1392,320C1344,320,1248,320,1152,320C1056,320,960,320,864,320C768,320,672,320,576,320C480,320,384,320,288,320C192,320,96,320,48,320L0,320Z">
|
||||
</path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Circle(props: SvgProps) {
|
||||
const radius = props.width && props.height ? Math.min(props.width, props.height) / 2 : 500;
|
||||
const dimension = radius * 2;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={props.className ? props.className : ""}
|
||||
style={{ ...props.style, overflow: 'visible' }}
|
||||
width={props.width ? props.width : dimension}
|
||||
height={props.height ? props.height : dimension}
|
||||
viewBox={`0 0 ${dimension} ${dimension}`}
|
||||
fill={props.fill ? props.fill : "current"}
|
||||
>
|
||||
<circle
|
||||
r={radius}
|
||||
cx={radius}
|
||||
cy={radius}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
21
src/components/svgs.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export function WaveSvg({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 1920 318" className={className ? className : ""} xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M224 31.3989C90.8 -21.9803 27 2.73867 0 31.8466V317.103H1920V292.921C1835 221.719 1789.3 264.35 1710.5 221.719C1612 168.429 1628.5 146.039 1514 100.81C1412.02 60.5246 1287.5 114.244 1180.5 146.039C1073.5 177.833 1010.5 193.955 852.5 146.039C694.5 98.1228 581.5 148.726 486 146.039C390.5 143.352 390.5 98.1229 224 31.3989Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Blob({ id, image, className, path } : { id: string, image: string, className?: string, path: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 418 457" className={className} xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id={id} x="0" y="0" width="1" height="1">
|
||||
<image x="0" y="0" width="100%" height="100%" preserveAspectRatio="xMaxYMax slice" href={image} />
|
||||
</pattern>
|
||||
</defs>
|
||||
<path fill={`url(#${id})`} d={path} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
70
src/components/tarification/card.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion"
|
||||
import OptimizedImage from "../global/optimized-image";
|
||||
|
||||
export interface CardData {
|
||||
title: string;
|
||||
description: string | React.ReactNode;
|
||||
image: {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
details: {
|
||||
title: string;
|
||||
content: string;
|
||||
};
|
||||
reverse?: boolean;
|
||||
};
|
||||
|
||||
export default function Card({ cardData, onImageClick }: { cardData: CardData, onImageClick?: () => void }) {
|
||||
return (
|
||||
<article className="w-full flex justify-center p-10">
|
||||
<div className="w-[1400px] flex-row">
|
||||
<div className="flex-row sm:flex">
|
||||
<div className={`${cardData.reverse ? "order-2" : "order-1"} relative`}>
|
||||
<OptimizedImage
|
||||
src={cardData.image.src}
|
||||
alt={cardData.image.alt}
|
||||
containerClassName="
|
||||
xl:w-[700px] xl:h-[596px]
|
||||
lg:w-[580px] lg:h-[494px]
|
||||
md:w-[480px] md:h-[408px]
|
||||
sm:w-[360px] sm:h-[306px]
|
||||
w-[360px] h-[306px]"
|
||||
className="rounded-2xl object-cover w-full h-full cursor-pointer hover:scale-105 transition-transform duration-300 shadow-xl"
|
||||
onClick={onImageClick}
|
||||
/>
|
||||
<hr className={`border-2 border-secondary rounded-lg mt-4 w-40 flex absolute ${cardData.reverse ? "right-0" : ""}`} />
|
||||
</div>
|
||||
<div className={`${cardData.reverse ? "order-1 sm:mr-4" : "order-2 sm:ml-4"} mt-8 sm:mt-0 w-full`}>
|
||||
<h1 className="text-3xl md:text-5xl font-bold uppercase">{cardData.title}</h1>
|
||||
<div className="text-sm lg:text-lg mt-2 md:mt-8">{cardData.description}</div>
|
||||
<div className="hidden md:flex">
|
||||
{/* <Details cardData={cardData} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:hidden sm:pt-8">
|
||||
{/* <Details cardData={cardData} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function Details({ cardData }: { cardData: CardData }) {
|
||||
return (
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>{cardData.details.title}</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{cardData.details.content}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)
|
||||
}
|
||||
129
src/components/tarification/tarification.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import Card, { CardData } from "@/components/tarification/card";
|
||||
import OptimizedImage from "../global/optimized-image";
|
||||
import Link from "next/link";
|
||||
import { ImageModal } from "@/components/global/fullscreen-image";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ImageData {
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
const localisationClass = "text-accent font-bold";
|
||||
|
||||
const tarificationData: CardData[] = [
|
||||
{
|
||||
title: "Sorties initiation",
|
||||
description: <ul className="list-disc list-inside">
|
||||
<li><Link target="_blank" href="https://maps.app.goo.gl/M4UcVu3E9Vc5Mgwm8">Grosdar 📍 <span className={localisationClass}> Saint-Claude Jura</span></Link></li>
|
||||
<li><Link target="_blank" href="https://maps.app.goo.gl/R8DYKPjqUcLpFNBf8">Chaley 📍 <span className={localisationClass}> Amberieux en bugey Ain</span></Link></li>
|
||||
<li><Link target="_blank" href="https://maps.app.goo.gl/cGfmwWgjwXfK26UC7">Malvaux 📍 <span className={localisationClass}> Région des Lacs Jura</span></Link></li>
|
||||
</ul>,
|
||||
image: {
|
||||
src: "/gallery/1.webp",
|
||||
alt: "Canyoning initiation dans le Jura - Canyon de Grosdar à Saint-Claude",
|
||||
},
|
||||
details: {
|
||||
title: "Tarifs et informations",
|
||||
content: "Yes. It's animated by default, but you can disable it if you prefer."
|
||||
},
|
||||
reverse: false,
|
||||
},
|
||||
{
|
||||
title: "Sorties Aventure",
|
||||
description: <ul className="list-disc list-inside">
|
||||
<li><Link target="_blank" href="https://maps.app.goo.gl/AATpgCHsFUR1hKrT7">Coiserette 📍 <span className={localisationClass}> Saint-Claude Jura</span></Link></li>
|
||||
<li><Link target="_blank" href="https://maps.app.goo.gl/a8ECvh8Rspo9KgPr8">Rhéby 📍 <span className={localisationClass}> Amberieux en bugey Ain</span></Link></li>
|
||||
<li><Link target="_blank" href="https://maps.app.goo.gl/b73whWfPjwyJyesu7">Langouette 📍 <span className={localisationClass}> Région des Lacs Jura</span></Link></li>
|
||||
</ul>,
|
||||
image: {
|
||||
src: "/gallery/10.webp",
|
||||
alt: "Canyoning aventure dans l'Ain - Canyon de Rhéby à Ambérieu-en-Bugey",
|
||||
},
|
||||
details: {
|
||||
title: "Tarifs et informations",
|
||||
content: "Yes. It's animated by default, but you can disable it if you prefer."
|
||||
},
|
||||
reverse: true,
|
||||
}
|
||||
];
|
||||
|
||||
const prices = {
|
||||
headers: ["Formule", "Tarifs"],
|
||||
rows: [
|
||||
["Demi-journée", "50€/pers"],
|
||||
["Journée", "85€/pers"],
|
||||
]
|
||||
}
|
||||
|
||||
export default function Tarification() {
|
||||
const [selectedImage, setSelectedImage] = useState<ImageData | null>(null);
|
||||
|
||||
return (
|
||||
<section className="section-padding" id="sorties">
|
||||
<div>
|
||||
<h1 className="main-title">SORTIES CANYONING<br />JURA & AIN</h1>
|
||||
<div className="flex justify-center">
|
||||
<p className="main-description">
|
||||
Découvrez nos canyons dans le Jura (Saint-Claude, Région des Lacs) et l'Ain (Ambérieu-en-Bugey).
|
||||
Sorties adaptées à tous niveaux, du débutant au confirmé. Matériel professionnel fourni.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{tarificationData.map((card, index) => (
|
||||
<Card key={index} cardData={card} onImageClick={() => setSelectedImage(card.image)} />
|
||||
))}
|
||||
<div className="flex justify-center py-20">
|
||||
<div className="w-[1400px]">
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-5xl font-bold uppercase text-center pb-2">Tarifs Canyoning Jura et Ain</h2>
|
||||
<p className="text-center pb-10">Je vous fourni le matériel essentiel (combinaison néoprène, casque, baudrier et chaussons).
|
||||
Encadrement professionnel diplômé d'État.</p>
|
||||
<div className="flex justify-center">
|
||||
<table className="table-auto border-collapse border border-muted-foreground text-lg">
|
||||
{prices.headers && (
|
||||
<thead>
|
||||
<tr>
|
||||
{prices.headers.map((header, index) => (
|
||||
<th key={index} className="px-8 py-4 border border-muted-foreground text-left font-bold text-xl">{header}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{prices.rows.map((row, index) => (
|
||||
<tr key={index}>
|
||||
{row.map((cell, index) => (
|
||||
<td key={index} className="px-8 py-4 border border-muted-foreground text-lg">{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-4 text-center">Pour réserver et pour plus d'informations <Link href="#contact" className="text-accent">me contacter.</Link></p>
|
||||
</div>
|
||||
{/* <p className="mt-2 text-center text-sm text-muted-foreground">*Tarifs réduits pour les chômeurs, étudiants (sur présentation d'un justificatif) et groupes à partir de 5 personnes.</p> */}
|
||||
</div>
|
||||
</div>
|
||||
<div id="map" className="flex justify-center py-20">
|
||||
<OptimizedImage
|
||||
src="/images/map.png"
|
||||
alt="Carte des sorties canyoning dans le Jura et l'Ain - Localisation des canyons Saint-Claude, Ambérieu-en-Bugey, Région des Lacs"
|
||||
fill
|
||||
className="rounded-lg object-cover"
|
||||
containerClassName="w-[350px] h-[350px] sm:w-[450px] sm:h-[450px] lg:w-[700px] lg:h-[700px] relative"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<hr className="w-[80%] text-foreground opacity-30 border-2 rounded"/>
|
||||
</div>
|
||||
|
||||
<ImageModal
|
||||
image={selectedImage as ImageData}
|
||||
isOpen={!!selectedImage}
|
||||
onClose={() => setSelectedImage(null)}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
56
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon } from "@radix-ui/react-icons"
|
||||
|
||||
const Accordion = AccordionPrimitive.Root
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AccordionPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn("border-b border-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
))
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pb-4 pt-0", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
))
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
50
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
57
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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 shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
76
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
30
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
122
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
205
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
DotFilledIcon,
|
||||
} from "@radix-ui/react-icons"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<DotFilledIcon className="h-4 w-4 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
178
src/components/ui/form.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
29
src/components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
25
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
26
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
33
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
48
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
164
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
CaretSortIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "@radix-ui/react-icons"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<CaretSortIcon className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
31
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
140
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
{children}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
15
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-foreground/30", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
29
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
120
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
55
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
24
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
130
src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Cross2Icon } from "@radix-ui/react-icons"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
success: "border bg-green-500/50 text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
35
src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
30
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
22
src/hooks/use-form-zod.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { z, ZodObject, ZodRawShape } from "zod";
|
||||
import UseZodShape from "./use-zod-shape";
|
||||
import { DefaultValues, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
export default function useFormZod<
|
||||
T extends ZodObject<ZodRawShape>,
|
||||
U extends Record<keyof ZodRawShape, unknown>
|
||||
>(zodSchema: T, data?: U) {
|
||||
const defaultValues = UseZodShape(zodSchema, data) as z.infer<
|
||||
typeof zodSchema
|
||||
>;
|
||||
|
||||
const form = useForm<z.infer<typeof zodSchema>>({
|
||||
resolver: zodResolver(zodSchema),
|
||||
defaultValues: defaultValues as DefaultValues<z.infer<typeof zodSchema>>,
|
||||
});
|
||||
|
||||
const { control, handleSubmit, formState } = form;
|
||||
|
||||
return { form, control, handleSubmit, formState };
|
||||
}
|
||||
194
src/hooks/use-toast.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client"
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
12
src/hooks/use-zod-shape.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ZodObject, ZodRawShape } from "zod";
|
||||
export default function UseZodShape<
|
||||
T extends ZodObject<ZodRawShape>,
|
||||
U extends Record<keyof ZodRawShape, unknown>
|
||||
>(zodSchema: T, data?: U) {
|
||||
const defaultValues = Object.keys(zodSchema.shape).reduce((acc, key) => {
|
||||
acc[key] = data && data[key] !== undefined ? data[key] : "";
|
||||
return acc;
|
||||
}, {} as Record<string, unknown>);
|
||||
|
||||
return defaultValues;
|
||||
}
|
||||
7
src/lib/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ApiErrorResponse } from "@/types/api";
|
||||
|
||||
export const apiInternalError: ApiErrorResponse = {
|
||||
ok: false,
|
||||
message: "Internal Server Error",
|
||||
};
|
||||
|
||||
18
src/lib/form.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export const getChangedFields = <
|
||||
T extends Record<string, any>,
|
||||
U extends Partial<T>
|
||||
>(
|
||||
oldObject: T,
|
||||
newObject: U
|
||||
): Partial<T> => {
|
||||
const changedFields: Partial<T> = {};
|
||||
|
||||
Object.keys(newObject).forEach((key) => {
|
||||
if (newObject[key] !== oldObject[key]) {
|
||||
(changedFields as any)[key] = newObject[key];
|
||||
}
|
||||
});
|
||||
|
||||
return changedFields;
|
||||
};
|
||||
24
src/lib/providers.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Providers:
|
||||
* Next-themes ThemeProvider
|
||||
* React-query QueryClientProvider
|
||||
*/
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function Providers({ children, ...props }: ThemeProviderProps) {
|
||||
return (
|
||||
<NextThemesProvider defaultTheme="system" {...props}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
12
src/middleware.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const cors = req.headers.get("Access-Control-Allow-Origin");
|
||||
const url = req.nextUrl.href;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: "/((?!api/auth|_next/static|_next/image|favicon.ico|ppt.svg).*)",
|
||||
};
|
||||
21
src/pages/_document.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Html, Head, Main, NextScript } from 'next/document'
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="fr">
|
||||
<Head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<meta name="msapplication-TileColor" content="#10b981" />
|
||||
<meta name="theme-color" content="#10b981" />
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
13
src/types/api.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
interface ApiSuccessResponse<T> {
|
||||
ok: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface ApiErrorResponse {
|
||||
ok: false;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
|
||||
|
||||
export { ApiErrorResponse, ApiSuccessResponse, ApiResponse };
|
||||
8
src/types/components.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||