This commit is contained in:
Sam
2026-01-05 13:45:51 +01:00
commit 2d15880b6d
103 changed files with 4390 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# openssl rand -base64 32
NEXTAUTH_SECRET="mega-complex-password"

9
.eslintrc.json Normal file
View 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
View 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"
}
}

View File

@@ -0,0 +1 @@
google-site-verification: google763a2192ab9a8564.html

6
next-env.d.ts vendored Normal file
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

BIN
public/activities/jump.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -0,0 +1 @@
/* Binary file - using logo.png from public/images/logo.png */

BIN
public/gallery/1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
public/gallery/10.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

BIN
public/gallery/11.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

BIN
public/gallery/2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
public/gallery/3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
public/gallery/4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
public/gallery/5.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

BIN
public/gallery/6.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
public/gallery/7.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

BIN
public/gallery/8.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
public/gallery/9.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

BIN
public/images/hero.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

BIN
public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

BIN
public/images/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
public/images/stef.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

23
public/manifest.json Normal file
View 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"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
public/nature/plants/1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

BIN
public/nature/plants/2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
public/nature/plants/3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

4
public/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://www.sourcesetnature.fr/sitemap.xml

9
public/sitemap.xml Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

101
src/app/globals.css Normal file
View 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
View 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
View 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>
);
}

View 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
View 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>
);
};

View 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&apos;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>
);
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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;

View 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;

View 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>
);
};

View 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>
);
}

View 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
View 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&apos;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
View 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>
)
}

View 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&apos;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>
)
}

View 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>
);
}

View 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>
);
}

View 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>
);
};

View 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
View 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>
)
}

View 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>
)
}

View 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&apos;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&apos;É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&apos;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>
)
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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,
}

View 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
View 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,
}

View 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 }

View 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 }

View 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 }

View 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 }

View 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 }

View 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,
}

View 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
View 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,
}

View 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 }

View 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
View 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,
}

View 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 }

View 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
View 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,
}

View 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>
)
}

View 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
View 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
View 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 }

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,8 @@
export interface IconProps {
className?: string;
}
interface SelectOption {
value: string;
label: string;
}

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