Initial commit

This commit is contained in:
Fares Kerkeni 2025-12-15 17:30:42 +01:00
commit 3d97d39bd3
48 changed files with 11611 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

18
eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

6400
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "signature-generator",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.86.0",
"canvas": "^3.2.0",
"gifenc": "^1.0.3",
"html-to-image": "^1.11.13",
"next": "16.0.7",
"react": "19.2.0",
"react-dom": "19.2.0",
"sass": "^1.94.2"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.7",
"typescript": "^5"
}
}

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/preview.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
public/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

File diff suppressed because one or more lines are too long

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,98 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import '@/styles/auth.scss'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
const supabase = createClient()
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) {
setError(error.message)
setLoading(false)
} else {
router.push('/dashboard')
router.refresh()
}
}
return (
<div className="auth">
<div className="auth__container">
<div className="auth__card">
<div className="auth__header">
<div className="auth__logo">SignGen</div>
<h1 className="auth__title">Connexion</h1>
<p className="auth__subtitle">
Connectez-vous pour créer vos signatures email
</p>
</div>
<form className="auth__form" onSubmit={handleLogin}>
{error && <div className="auth__error">{error}</div>}
<div className="auth__field">
<label className="label" htmlFor="email">
Email
</label>
<input
id="email"
type="email"
className="input"
placeholder="votre@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="auth__field">
<label className="label" htmlFor="password">
Mot de passe
</label>
<input
id="password"
type="password"
className="input"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button
type="submit"
className="btn btn--primary auth__submit"
disabled={loading}
>
{loading ? 'Connexion...' : 'Se connecter'}
</button>
</form>
<div className="auth__footer">
Pas encore de compte ?{' '}
<Link href="/register">Créer un compte</Link>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,154 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import '@/styles/auth.scss'
export default function RegisterPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const [loading, setLoading] = useState(false)
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
if (password !== confirmPassword) {
setError('Les mots de passe ne correspondent pas')
setLoading(false)
return
}
if (password.length < 6) {
setError('Le mot de passe doit contenir au moins 6 caractères')
setLoading(false)
return
}
const supabase = createClient()
const { error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/api/auth/callback`,
},
})
if (error) {
setError(error.message)
setLoading(false)
} else {
setSuccess(true)
setLoading(false)
}
}
if (success) {
return (
<div className="auth">
<div className="auth__container">
<div className="auth__card">
<div className="auth__header">
<div className="auth__logo">SignGen</div>
<h1 className="auth__title">Vérifiez votre email</h1>
<p className="auth__subtitle">
Un email de confirmation a é envoyé à <strong>{email}</strong>.
Cliquez sur le lien pour activer votre compte.
</p>
</div>
<div className="auth__footer">
<Link href="/login">Retour à la connexion</Link>
</div>
</div>
</div>
</div>
)
}
return (
<div className="auth">
<div className="auth__container">
<div className="auth__card">
<div className="auth__header">
<div className="auth__logo">SignGen</div>
<h1 className="auth__title">Créer un compte</h1>
<p className="auth__subtitle">
Inscrivez-vous pour créer vos signatures email
</p>
</div>
<form className="auth__form" onSubmit={handleRegister}>
{error && <div className="auth__error">{error}</div>}
<div className="auth__field">
<label className="label" htmlFor="email">
Email
</label>
<input
id="email"
type="email"
className="input"
placeholder="votre@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="auth__field">
<label className="label" htmlFor="password">
Mot de passe
</label>
<input
id="password"
type="password"
className="input"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
<div className="auth__field">
<label className="label" htmlFor="confirmPassword">
Confirmer le mot de passe
</label>
<input
id="confirmPassword"
type="password"
className="input"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={6}
/>
</div>
<button
type="submit"
className="btn btn--primary auth__submit"
disabled={loading}
>
{loading ? 'Inscription...' : "S'inscrire"}
</button>
</form>
<div className="auth__footer">
Déjà un compte ?{' '}
<Link href="/login">Se connecter</Link>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,18 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/dashboard'
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
return NextResponse.redirect(`${origin}/login?error=auth_callback_error`)
}

283
src/app/dashboard/page.tsx Normal file
View File

@ -0,0 +1,283 @@
'use client'
import { useRef, useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import SignatureEditor from '@/components/SignatureEditor'
import SignaturePreview from '@/components/SignaturePreview'
import StyleSelector from '@/components/StyleSelector'
import BannerGenerator from '@/components/BannerGenerator'
import { useSignatures, SavedSignature } from '@/hooks/useSignatures'
import { colorThemes, SignatureStyle } from '@/lib/types'
import { generateSignatureHTML, exportAsImage, copyHTMLToClipboard, downloadHTML } from '@/lib/export'
import '@/styles/dashboard.scss'
export default function DashboardPage() {
const router = useRouter()
const [user, setUser] = useState<{ email: string } | null>(null)
const [copySuccess, setCopySuccess] = useState(false)
const [exportLoading, setExportLoading] = useState(false)
const [activeTab, setActiveTab] = useState<'editor' | 'style' | 'banner'>('editor')
const signatureRef = useRef<HTMLDivElement>(null)
const {
signatures,
currentSignature,
currentId,
loading,
saving,
updateSignature,
createNewSignature,
selectSignature,
deleteSignature,
} = useSignatures()
useEffect(() => {
const supabase = createClient()
supabase.auth.getUser().then(({ data }) => {
if (data.user) {
setUser({ email: data.user.email || '' })
} else {
router.push('/login')
}
})
}, [router])
const handleLogout = async () => {
const supabase = createClient()
await supabase.auth.signOut()
router.push('/login')
router.refresh()
}
const handleNewSignature = async () => {
await createNewSignature()
setActiveTab('editor')
}
const handleStyleChange = (style: SignatureStyle) => {
updateSignature({ ...currentSignature, styleTemplate: style })
}
const handleThemeChange = (themeIndex: number) => {
const theme = colorThemes[themeIndex]
updateSignature({
...currentSignature,
primaryColor: theme.primary,
secondaryColor: theme.secondary,
accentColor: theme.accent,
})
}
const handleBannerChange = (url: string) => {
updateSignature({ ...currentSignature, bannerUrl: url })
}
const handleBannerLinkChange = (link: string) => {
updateSignature({ ...currentSignature, bannerLink: link })
}
const handleCopyHTML = async () => {
const html = generateSignatureHTML(currentSignature)
try {
await copyHTMLToClipboard(html)
setCopySuccess(true)
setTimeout(() => setCopySuccess(false), 2000)
} catch (error) {
console.error('Error copying HTML:', error)
}
}
const handleDownloadHTML = () => {
const html = generateSignatureHTML(currentSignature)
const filename = `signature-${currentSignature.firstName || 'email'}-${currentSignature.lastName || ''}.html`
downloadHTML(html, filename)
}
const handleExportImage = async () => {
if (!signatureRef.current) return
setExportLoading(true)
try {
await exportAsImage(signatureRef.current)
} catch (error) {
console.error('Error exporting image:', error)
} finally {
setExportLoading(false)
}
}
const handleDeleteSignature = async (sig: SavedSignature, e: React.MouseEvent) => {
e.stopPropagation()
if (confirm(`Supprimer la signature de ${sig.firstName} ${sig.lastName} ?`)) {
await deleteSignature(sig.id)
}
}
if (loading) {
return (
<div className="dashboard">
<div className="loading-screen">
<div className="loading-screen__spinner" />
<p>Chargement...</p>
</div>
</div>
)
}
return (
<div className="dashboard">
{/* Header */}
<header className="header">
<div className="header__inner">
<div className="header__brand">Navier Signatures</div>
<div className="header__actions">
{saving && <span className="auto-save">Sauvegarde...</span>}
{user && <span className="header__email">{user.email}</span>}
<button className="btn btn--ghost" onClick={handleLogout}>
Déconnexion
</button>
</div>
</div>
</header>
<div className="app-layout">
{/* Sidebar */}
<aside className="sidebar">
<div className="sidebar__top">
<h2>Signatures</h2>
<button className="btn btn--primary btn--icon" onClick={handleNewSignature} title="Nouvelle signature">
+
</button>
</div>
<div className="signature-list">
{signatures.length === 0 ? (
<div className="signature-list__empty">
<p>Aucune signature</p>
<button className="btn btn--primary" onClick={handleNewSignature}>
Créer une signature
</button>
</div>
) : (
signatures.map((sig) => (
<div
key={sig.id}
className={`signature-card ${currentId === sig.id ? 'signature-card--active' : ''}`}
onClick={() => selectSignature(sig)}
>
<div className="signature-card__avatar">
{sig.firstName.charAt(0)}{sig.lastName.charAt(0)}
</div>
<div className="signature-card__info">
<span className="signature-card__name">{sig.firstName} {sig.lastName}</span>
<span className="signature-card__role">{sig.jobTitle || 'Sans titre'}</span>
</div>
<button
className="signature-card__delete"
onClick={(e) => handleDeleteSignature(sig, e)}
title="Supprimer"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" />
</svg>
</button>
</div>
))
)}
</div>
</aside>
{/* Main content */}
<main className="main-content">
{/* Tabs */}
<nav className="tabs">
<button
className={`tabs__item ${activeTab === 'editor' ? 'tabs__item--active' : ''}`}
onClick={() => setActiveTab('editor')}
>
Informations
</button>
<button
className={`tabs__item ${activeTab === 'style' ? 'tabs__item--active' : ''}`}
onClick={() => setActiveTab('style')}
>
Style
</button>
<button
className={`tabs__item ${activeTab === 'banner' ? 'tabs__item--active' : ''}`}
onClick={() => setActiveTab('banner')}
>
Bannière
</button>
</nav>
<div className="content-grid">
{/* Editor Panel */}
<section className="panel">
{activeTab === 'editor' && (
<SignatureEditor data={currentSignature} onChange={updateSignature} />
)}
{activeTab === 'style' && (
<StyleSelector
currentStyle={currentSignature.styleTemplate}
currentThemeIndex={colorThemes.findIndex(t => t.primary === currentSignature.primaryColor)}
onStyleChange={handleStyleChange}
onThemeChange={handleThemeChange}
photoShape={currentSignature.photoShape}
onPhotoShapeChange={(shape) => updateSignature({ ...currentSignature, photoShape: shape })}
/>
)}
{activeTab === 'banner' && (
<BannerGenerator
bannerUrl={currentSignature.bannerUrl || ''}
bannerLink={currentSignature.bannerLink || ''}
onBannerChange={handleBannerChange}
onLinkChange={handleBannerLinkChange}
/>
)}
</section>
{/* Preview Panel */}
<section className="panel panel--preview">
<h3 className="panel__title">Aperçu</h3>
<div className="preview-box">
<SignaturePreview
ref={signatureRef}
data={currentSignature}
bannerUrl={currentSignature.bannerUrl}
bannerLink={currentSignature.bannerLink}
/>
</div>
<div className="export-actions">
<button
className={`btn ${copySuccess ? 'btn--success' : 'btn--primary'}`}
onClick={handleCopyHTML}
>
{copySuccess ? 'Copié !' : 'Copier HTML'}
</button>
<button className="btn btn--secondary" onClick={handleDownloadHTML}>
Télécharger
</button>
<button
className="btn btn--secondary"
onClick={handleExportImage}
disabled={exportLoading}
>
{exportLoading ? '...' : 'PNG'}
</button>
</div>
<p className="preview-hint">
Collez le HTML dans les paramètres de signature de votre client email.
</p>
</section>
</div>
</main>
</div>
</div>
)
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

19
src/app/layout.tsx Normal file
View File

@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "@/styles/globals.scss";
export const metadata: Metadata = {
title: "SignGen - Générateur de Signatures Email",
description: "Créez des signatures email professionnelles avec bannière en quelques clics",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr">
<body>{children}</body>
</html>
);
}

70
src/app/page.tsx Normal file
View File

@ -0,0 +1,70 @@
import Link from 'next/link'
import '@/styles/home.scss'
export default function HomePage() {
return (
<div className="home">
<header className="home__header">
<div className="home__header-content">
<div className="home__logo">SignGen</div>
<nav className="home__nav">
<Link href="/login" className="btn btn--outline">
Connexion
</Link>
<Link href="/register" className="btn btn--primary">
S'inscrire
</Link>
</nav>
</div>
</header>
<main className="home__main">
<div className="home__hero">
<h1 className="home__title">
Créez des signatures email
<span className="home__title-accent"> professionnelles</span>
</h1>
<p className="home__subtitle">
Générez facilement des signatures email avec bannières promotionnelles.
Exportez en HTML ou PNG en un clic.
</p>
<div className="home__cta">
<Link href="/register" className="btn btn--primary btn--large">
Commencer gratuitement
</Link>
</div>
</div>
<div className="home__features">
<div className="home__feature">
<div className="home__feature-icon"></div>
<h3 className="home__feature-title">Éditeur simple</h3>
<p className="home__feature-text">
Remplissez vos informations et personnalisez les couleurs en temps réel
</p>
</div>
<div className="home__feature">
<div className="home__feature-icon">🖼</div>
<h3 className="home__feature-title">Bannières promo</h3>
<p className="home__feature-text">
Ajoutez une bannière promotionnelle cliquable à votre signature
</p>
</div>
<div className="home__feature">
<div className="home__feature-icon">📤</div>
<h3 className="home__feature-title">Export facile</h3>
<p className="home__feature-text">
Téléchargez en HTML ou PNG, compatible Gmail et Outlook
</p>
</div>
</div>
</main>
<footer className="home__footer">
<p>© 2024 SignGen. Tous droits réservés.</p>
</footer>
</div>
)
}

View File

@ -0,0 +1,345 @@
'use client'
import { useState, useRef, useEffect } from 'react'
interface BannerGeneratorProps {
bannerUrl: string
bannerLink: string
onBannerChange: (url: string) => void
onLinkChange: (link: string) => void
}
interface BannerTemplate {
id: string
name: string
colors: { bg1: string; bg2: string; accent: string; text: string }
}
const bannerTemplates: BannerTemplate[] = [
{ id: 'navier', name: 'Navier Gold', colors: { bg1: '#1a1a2e', bg2: '#16213e', accent: '#F5A623', text: '#ffffff' } },
{ id: 'ocean', name: 'Ocean', colors: { bg1: '#0077B6', bg2: '#023E8A', accent: '#90E0EF', text: '#ffffff' } },
{ id: 'forest', name: 'Forest', colors: { bg1: '#2D6A4F', bg2: '#1B4332', accent: '#95D5B2', text: '#ffffff' } },
{ id: 'sunset', name: 'Sunset', colors: { bg1: '#E85D04', bg2: '#9D0208', accent: '#FFBA08', text: '#ffffff' } },
{ id: 'royal', name: 'Royal', colors: { bg1: '#7209B7', bg2: '#3A0CA3', accent: '#4CC9F0', text: '#ffffff' } },
{ id: 'charcoal', name: 'Charcoal', colors: { bg1: '#495057', bg2: '#212529', accent: '#ADB5BD', text: '#ffffff' } },
{ id: 'coral', name: 'Coral', colors: { bg1: '#FF6B6B', bg2: '#C92A2A', accent: '#FFE066', text: '#ffffff' } },
{ id: 'mint', name: 'Mint', colors: { bg1: '#12B886', bg2: '#087F5B', accent: '#C3FAE8', text: '#ffffff' } },
{ id: 'tech', name: 'Tech Dark', colors: { bg1: '#0f172a', bg2: '#1e293b', accent: '#38bdf8', text: '#ffffff' } },
{ id: 'minimal', name: 'Minimal', colors: { bg1: '#ffffff', bg2: '#f8fafc', accent: '#3b82f6', text: '#1e293b' } },
]
export default function BannerGenerator({
bannerUrl,
bannerLink,
onBannerChange,
onLinkChange,
}: BannerGeneratorProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [selectedTemplate, setSelectedTemplate] = useState<BannerTemplate>(bannerTemplates[0])
const [bannerTitle, setBannerTitle] = useState('Découvrez nos nouvelles solutions')
const [bannerSubtitle, setBannerSubtitle] = useState('Offre spéciale -20% jusqu\'au 31 décembre')
const [ctaText, setCtaText] = useState('En savoir plus')
const [generating, setGenerating] = useState(false)
const animationRef = useRef<number | undefined>(undefined)
const frameRef = useRef<number>(0)
// Animation de preview
useEffect(() => {
const animate = () => {
frameRef.current = (frameRef.current + 1) % 120
drawBanner(frameRef.current)
animationRef.current = requestAnimationFrame(animate)
}
animate()
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
}
}, [selectedTemplate, bannerTitle, bannerSubtitle, ctaText])
const drawBanner = (frame: number) => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const width = 600
const height = 150
const colors = selectedTemplate.colors
// Clear
ctx.clearRect(0, 0, width, height)
// Background gradient
const gradient = ctx.createLinearGradient(0, 0, width, height)
gradient.addColorStop(0, colors.bg1)
gradient.addColorStop(1, colors.bg2)
ctx.fillStyle = gradient
ctx.fillRect(0, 0, width, height)
// Decorative elements based on template
drawDecorations(ctx, width, height, frame, colors)
// Content
drawContent(ctx, width, height, frame, colors)
}
const drawDecorations = (
ctx: CanvasRenderingContext2D,
width: number,
height: number,
frame: number,
colors: BannerTemplate['colors']
) => {
const accentRgb = hexToRgb(colors.accent)
// Animated circles
ctx.fillStyle = `rgba(${accentRgb.r}, ${accentRgb.g}, ${accentRgb.b}, 0.15)`
ctx.beginPath()
ctx.arc(
width - 80 + Math.sin(frame * 0.03) * 10,
30 + Math.cos(frame * 0.02) * 5,
60,
0,
Math.PI * 2
)
ctx.fill()
ctx.fillStyle = `rgba(${accentRgb.r}, ${accentRgb.g}, ${accentRgb.b}, 0.1)`
ctx.beginPath()
ctx.arc(
width - 120,
height - 20 + Math.sin(frame * 0.04) * 8,
40,
0,
Math.PI * 2
)
ctx.fill()
// Animated wave at bottom
ctx.fillStyle = `rgba(${accentRgb.r}, ${accentRgb.g}, ${accentRgb.b}, 0.1)`
ctx.beginPath()
ctx.moveTo(0, height)
for (let x = 0; x <= width; x += 10) {
const y = height - 15 + Math.sin((x + frame * 2) * 0.02) * 8
ctx.lineTo(x, y)
}
ctx.lineTo(width, height)
ctx.closePath()
ctx.fill()
// Grid pattern for tech style
if (selectedTemplate.id === 'tech') {
ctx.strokeStyle = `rgba(${accentRgb.r}, ${accentRgb.g}, ${accentRgb.b}, 0.1)`
ctx.lineWidth = 1
for (let x = 0; x < width; x += 30) {
ctx.beginPath()
ctx.moveTo(x, 0)
ctx.lineTo(x, height)
ctx.stroke()
}
for (let y = 0; y < height; y += 30) {
ctx.beginPath()
ctx.moveTo(0, y)
ctx.lineTo(width, y)
ctx.stroke()
}
}
// Left accent bar for minimal
if (selectedTemplate.id === 'minimal') {
ctx.fillStyle = colors.accent
ctx.fillRect(0, 0, 6, height)
}
}
const drawContent = (
ctx: CanvasRenderingContext2D,
width: number,
height: number,
frame: number,
colors: BannerTemplate['colors']
) => {
const textX = selectedTemplate.id === 'minimal' ? 30 : 25
// Title
ctx.textAlign = 'left'
ctx.textBaseline = 'middle'
ctx.font = 'bold 22px Arial, sans-serif'
ctx.fillStyle = colors.text
const titleY = height / 2 - 25 + Math.sin(frame * 0.05) * 1
ctx.fillText(bannerTitle, textX, titleY)
// Subtitle
ctx.font = '14px Arial, sans-serif'
ctx.fillStyle = colors.accent
ctx.fillText(bannerSubtitle, textX, height / 2 + 5)
// CTA Button
if (ctaText) {
const btnX = textX
const btnY = height / 2 + 35
const btnPadding = 16
const btnHeight = 28
ctx.font = '13px Arial, sans-serif'
const btnWidth = ctx.measureText(ctaText).width + btnPadding * 2
// Button background
ctx.fillStyle = colors.accent
ctx.beginPath()
ctx.roundRect(btnX, btnY - btnHeight / 2, btnWidth, btnHeight, 14)
ctx.fill()
// Button text
ctx.fillStyle = selectedTemplate.id === 'minimal' || selectedTemplate.id === 'charcoal'
? colors.bg1
: '#1a1a2e'
ctx.textAlign = 'center'
ctx.fillText(ctaText, btnX + btnWidth / 2, btnY)
// Arrow
ctx.fillStyle = selectedTemplate.id === 'minimal' ? colors.bg1 : '#1a1a2e'
const arrowX = btnX + btnWidth - 20
ctx.beginPath()
ctx.moveTo(arrowX, btnY - 3)
ctx.lineTo(arrowX + 5, btnY)
ctx.lineTo(arrowX, btnY + 3)
ctx.fill()
}
}
const hexToRgb = (hex: string) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : { r: 245, g: 166, b: 35 }
}
const generateBanner = async () => {
setGenerating(true)
// Draw final static frame
drawBanner(0)
const dataUrl = canvasRef.current?.toDataURL('image/png')
if (dataUrl) {
onBannerChange(dataUrl)
}
setGenerating(false)
}
return (
<div className="banner-generator">
{/* Preview animé */}
<div className="banner-generator__preview">
<canvas
ref={canvasRef}
width={600}
height={150}
style={{ width: '100%', height: 'auto', borderRadius: '8px' }}
/>
</div>
{/* Sélecteur de template */}
<div className="banner-generator__section">
<label className="banner-generator__label">Style de couleur</label>
<div className="banner-generator__templates">
{bannerTemplates.map((template) => (
<button
key={template.id}
className={`banner-template ${selectedTemplate.id === template.id ? 'banner-template--active' : ''}`}
onClick={() => setSelectedTemplate(template)}
title={template.name}
>
<div
className="banner-template__preview"
style={{
background: `linear-gradient(135deg, ${template.colors.bg1} 0%, ${template.colors.bg2} 100%)`
}}
>
<span style={{ backgroundColor: template.colors.accent }} />
</div>
</button>
))}
</div>
</div>
{/* Champs de texte */}
<div className="banner-generator__section">
<label className="banner-generator__label">Titre principal</label>
<input
type="text"
className="input"
value={bannerTitle}
onChange={(e) => setBannerTitle(e.target.value)}
placeholder="Découvrez nos nouvelles solutions"
/>
</div>
<div className="banner-generator__section">
<label className="banner-generator__label">Sous-titre / Offre</label>
<input
type="text"
className="input"
value={bannerSubtitle}
onChange={(e) => setBannerSubtitle(e.target.value)}
placeholder="Offre spéciale -20%..."
/>
</div>
<div className="banner-generator__row">
<div className="banner-generator__section">
<label className="banner-generator__label">Bouton CTA</label>
<input
type="text"
className="input"
value={ctaText}
onChange={(e) => setCtaText(e.target.value)}
placeholder="En savoir plus"
/>
</div>
<div className="banner-generator__section">
<label className="banner-generator__label">Lien URL</label>
<input
type="url"
className="input"
value={bannerLink}
onChange={(e) => onLinkChange(e.target.value)}
placeholder="https://..."
/>
</div>
</div>
{/* Actions */}
<div className="banner-generator__actions">
<button
className="btn btn--primary"
onClick={generateBanner}
disabled={generating}
>
{generating ? 'Génération...' : 'Appliquer cette bannière'}
</button>
{bannerUrl && (
<button
className="btn btn--outline"
onClick={() => onBannerChange('')}
>
Supprimer
</button>
)}
</div>
<p className="banner-generator__hint">
La bannière apparaîtra en bas de votre signature email.
</p>
</div>
)
}

View File

@ -0,0 +1,75 @@
'use client'
import { useRef } from 'react'
interface BannerUploadProps {
value: string
onChange: (url: string) => void
label: string
hint?: string
}
export default function BannerUpload({ value, onChange, label, hint }: BannerUploadProps) {
const inputRef = useRef<HTMLInputElement>(null)
const handleClick = () => {
inputRef.current?.click()
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
// Convert to base64 for preview
const reader = new FileReader()
reader.onloadend = () => {
onChange(reader.result as string)
}
reader.readAsDataURL(file)
}
}
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
onChange('')
if (inputRef.current) {
inputRef.current.value = ''
}
}
return (
<div
className={`banner-upload ${value ? 'banner-upload--has-image' : ''}`}
onClick={handleClick}
>
<input
ref={inputRef}
type="file"
accept="image/*"
className="banner-upload__input"
onChange={handleChange}
/>
{value ? (
<div className="banner-upload__preview">
<img src={value} alt={label} />
<button
type="button"
className="banner-upload__remove"
onClick={handleRemove}
title="Supprimer"
>
×
</button>
</div>
) : (
<div className="banner-upload__placeholder">
<span className="banner-upload__icon">📷</span>
<span className="banner-upload__text">
Cliquez pour ajouter une {label.toLowerCase()}
</span>
{hint && <span className="banner-upload__hint">{hint}</span>}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,105 @@
'use client'
import { useRef, useState } from 'react'
import { uploadImage } from '@/lib/upload'
interface ImageUploadProps {
value: string
onChange: (url: string) => void
folder?: string
label?: string
hint?: string
aspectRatio?: string
}
export default function ImageUpload({
value,
onChange,
folder = 'images',
label = 'image',
hint,
aspectRatio = '4 / 1'
}: ImageUploadProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleClick = () => {
if (!uploading) {
inputRef.current?.click()
}
}
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
setError(null)
const result = await uploadImage(file, folder)
if (result.success && result.url) {
onChange(result.url)
} else {
setError(result.error || 'Erreur upload')
}
setUploading(false)
// Reset input
if (inputRef.current) {
inputRef.current.value = ''
}
}
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation()
onChange('')
}
return (
<div
className={`banner-upload ${value ? 'banner-upload--has-image' : ''}`}
onClick={handleClick}
style={{ cursor: uploading ? 'wait' : 'pointer' }}
>
<input
ref={inputRef}
type="file"
accept="image/*"
className="banner-upload__input"
onChange={handleChange}
disabled={uploading}
/>
{uploading ? (
<div className="banner-upload__placeholder">
<span className="banner-upload__icon"></span>
<span className="banner-upload__text">Upload en cours...</span>
</div>
) : value ? (
<div className="banner-upload__preview">
<img src={value} alt={label} style={{ aspectRatio }} />
<button
type="button"
className="banner-upload__remove"
onClick={handleRemove}
title="Supprimer"
>
×
</button>
</div>
) : (
<div className="banner-upload__placeholder">
<span className="banner-upload__icon">📷</span>
<span className="banner-upload__text">
Cliquez pour uploader une {label.toLowerCase()}
</span>
{hint && <span className="banner-upload__hint">{hint}</span>}
{error && <span className="banner-upload__hint" style={{ color: '#e74c3c' }}>{error}</span>}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,220 @@
'use client'
import { SignatureData } from '@/lib/types'
import BannerUpload from './BannerUpload'
interface SignatureEditorProps {
data: SignatureData
onChange: (data: SignatureData) => void
}
export default function SignatureEditor({ data, onChange }: SignatureEditorProps) {
const updateField = (field: keyof SignatureData, value: string) => {
onChange({ ...data, [field]: value })
}
return (
<div className="editor">
{/* Personal Information */}
<h4 className="editor__subtitle">Informations personnelles</h4>
<div className="editor__row">
<div className="editor__group">
<label className="label" htmlFor="firstName">Prénom</label>
<input
id="firstName"
type="text"
className="input"
placeholder="Jean"
value={data.firstName}
onChange={(e) => updateField('firstName', e.target.value)}
/>
</div>
<div className="editor__group">
<label className="label" htmlFor="lastName">Nom</label>
<input
id="lastName"
type="text"
className="input"
placeholder="Dupont"
value={data.lastName}
onChange={(e) => updateField('lastName', e.target.value)}
/>
</div>
</div>
<div className="editor__row">
<div className="editor__group">
<label className="label" htmlFor="jobTitle">Poste</label>
<input
id="jobTitle"
type="text"
className="input"
placeholder="Directeur Commercial"
value={data.jobTitle}
onChange={(e) => updateField('jobTitle', e.target.value)}
/>
</div>
<div className="editor__group">
<label className="label" htmlFor="company">Entreprise</label>
<input
id="company"
type="text"
className="input"
placeholder="Ma Société"
value={data.company}
onChange={(e) => updateField('company', e.target.value)}
/>
</div>
</div>
<div className="editor__divider" />
{/* Contact Information */}
<h4 className="editor__subtitle">Contact</h4>
<div className="editor__group">
<label className="label" htmlFor="email">Email</label>
<input
id="email"
type="email"
className="input"
placeholder="jean.dupont@societe.com"
value={data.email}
onChange={(e) => updateField('email', e.target.value)}
/>
</div>
<div className="editor__row">
<div className="editor__group">
<label className="label" htmlFor="phone">Téléphone</label>
<input
id="phone"
type="tel"
className="input"
placeholder="+33 1 23 45 67 89"
value={data.phone}
onChange={(e) => updateField('phone', e.target.value)}
/>
</div>
<div className="editor__group">
<label className="label" htmlFor="mobile">Mobile</label>
<input
id="mobile"
type="tel"
className="input"
placeholder="+33 6 12 34 56 78"
value={data.mobile}
onChange={(e) => updateField('mobile', e.target.value)}
/>
</div>
</div>
<div className="editor__group">
<label className="label" htmlFor="website">Site web</label>
<input
id="website"
type="text"
className="input"
placeholder="www.masociete.com"
value={data.website}
onChange={(e) => updateField('website', e.target.value)}
/>
</div>
<div className="editor__group">
<label className="label" htmlFor="address">Adresse</label>
<input
id="address"
type="text"
className="input"
placeholder="123 Rue de Paris, 75001 Paris"
value={data.address}
onChange={(e) => updateField('address', e.target.value)}
/>
</div>
<div className="editor__divider" />
{/* Logo & Photo */}
<h4 className="editor__subtitle">Logo & Photo</h4>
<div className="editor__row">
<div className="editor__group">
<label className="label">Logo entreprise</label>
<BannerUpload
value={data.logoUrl}
onChange={(url) => updateField('logoUrl', url)}
label="Logo"
hint="Format carré (100x100px)"
/>
</div>
<div className="editor__group">
<label className="label">Photo de profil</label>
<BannerUpload
value={data.photoUrl}
onChange={(url) => updateField('photoUrl', url)}
label="Photo"
hint="Image carrée (200x200px)"
/>
</div>
</div>
<div className="editor__divider" />
{/* Social Links */}
<h4 className="editor__subtitle">Réseaux sociaux</h4>
<div className="editor__row">
<div className="editor__group">
<label className="label" htmlFor="linkedin">LinkedIn</label>
<input
id="linkedin"
type="url"
className="input"
placeholder="https://linkedin.com/in/..."
value={data.linkedin}
onChange={(e) => updateField('linkedin', e.target.value)}
/>
</div>
<div className="editor__group">
<label className="label" htmlFor="twitter">Twitter / X</label>
<input
id="twitter"
type="url"
className="input"
placeholder="https://twitter.com/..."
value={data.twitter}
onChange={(e) => updateField('twitter', e.target.value)}
/>
</div>
</div>
<div className="editor__row">
<div className="editor__group">
<label className="label" htmlFor="facebook">Facebook</label>
<input
id="facebook"
type="url"
className="input"
placeholder="https://facebook.com/..."
value={data.facebook}
onChange={(e) => updateField('facebook', e.target.value)}
/>
</div>
<div className="editor__group">
<label className="label" htmlFor="instagram">Instagram</label>
<input
id="instagram"
type="url"
className="input"
placeholder="https://instagram.com/..."
value={data.instagram}
onChange={(e) => updateField('instagram', e.target.value)}
/>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,274 @@
// =======================================
// SIGNATURE PREVIEW - TEMPLATE STYLE
// =======================================
// Variables (couleurs du logo Navier)
$accent: #3498db; // Bleu Navier
$dark: #3a3a3a; // Gris foncé
$text: #333333;
$textLight: #666666;
// Container principal
.signature {
display: flex;
align-items: stretch;
background: #ffffff;
border-top: 3px solid $accent;
border-bottom: 3px solid $accent;
max-width: 600px;
font-family: Arial, sans-serif;
}
// =======================================
// PARTIE GAUCHE - Formes + Photo
// =======================================
.leftSection {
position: relative;
width: 160px;
min-height: 140px;
flex-shrink: 0;
}
// Trapèze jaune (derrière) - forme de chevron gauche
.trapezoidYellow {
position: absolute;
top: 50%;
left: 10px;
width: 70px;
height: 120px;
background: $accent;
clip-path: polygon(100% 0, 0 50%, 100% 100%);
transform: translateY(-50%);
}
// Trapèze bleu foncé (devant) - forme de chevron droit
.trapezoidDark {
position: absolute;
top: 50%;
left: 35px;
width: 70px;
height: 120px;
background: $dark;
clip-path: polygon(0 0, 100% 50%, 0 100%);
transform: translateY(-50%);
}
// Container photo hexagonale
.photoContainer {
position: absolute;
left: 30px;
top: 50%;
transform: translateY(-50%);
width: 100px;
height: 100px;
z-index: 10;
}
// Hexagone (bordure)
.hexagonBorder {
width: 100%;
height: 100%;
background: $dark;
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
display: flex;
align-items: center;
justify-content: center;
}
// Hexagone intérieur (photo)
.hexagonInner {
width: 92px;
height: 92px;
background: #ffffff;
clip-path: polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.photo {
width: 100%;
height: 100%;
object-fit: cover;
}
// =======================================
// PARTIE CENTRALE - Infos
// =======================================
.centerSection {
flex: 1;
padding: 15px 20px;
display: flex;
flex-direction: column;
justify-content: center;
}
// Triangle décoratif en haut
.triangleTop {
align-self: flex-end;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 12px solid $accent;
margin-bottom: 8px;
}
.name {
font-size: 18px;
font-weight: 700;
color: $dark;
margin: 0 0 2px 0;
}
.jobTitle {
font-size: 13px;
color: $textLight;
margin: 0;
}
// Barre de séparation horizontale
.separator {
width: 100%;
height: 3px;
background: $accent;
margin: 10px 0;
}
// Contacts en 2 colonnes
.contactGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 20px;
}
.contactItem {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: $text;
}
// Icône ronde
.contactIcon {
width: 18px;
height: 18px;
background: $dark;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
svg {
width: 10px;
height: 10px;
fill: #ffffff;
}
}
.contactLink {
color: $text;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
// Adresse (pleine largeur)
.address {
grid-column: 1 / -1;
}
// =======================================
// PARTIE DROITE - Logo + Social
// =======================================
.rightSection {
width: 110px;
padding: 15px 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
}
// Triangle décoratif en haut à droite
.triangleTopRight {
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-top: 30px solid $accent;
border-left: 25px solid transparent;
}
// Triangle outline en bas à droite
.triangleBottomRight {
position: absolute;
bottom: 10px;
right: 10px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 12px solid transparent;
border-bottom-color: $accent;
// Pour faire un outline, on peut utiliser un pseudo-element
}
.logo {
display: block;
width: 80px;
height: auto;
max-height: 50px;
object-fit: contain;
margin-bottom: 10px;
}
// Icônes réseaux sociaux
.socialIcons {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: center;
}
.socialIcon {
width: 24px;
height: 24px;
background: $dark;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
svg {
width: 12px;
height: 12px;
fill: #ffffff;
}
&:hover {
opacity: 0.8;
}
}
// =======================================
// BANNIÈRE PROMO
// =======================================
.banner {
margin-top: 12px;
img {
max-width: 100%;
height: auto;
display: block;
border-radius: 4px;
}
}

View File

@ -0,0 +1,167 @@
"use client";
import { SignatureData } from "@/lib/types";
import { forwardRef } from "react";
import Styles from "./SignaturePreview.module.scss";
interface SignaturePreviewProps {
data: SignatureData;
bannerUrl?: string;
bannerLink?: string;
}
const SignaturePreview = forwardRef<HTMLDivElement, SignaturePreviewProps>(
({ data, bannerUrl, bannerLink }, ref) => {
const fullName = `${data.firstName} ${data.lastName}`.trim();
return (
<div ref={ref}>
{/* ========== SIGNATURE CONTAINER ========== */}
<div className={Styles.signature}>
{/* ===== PARTIE GAUCHE - Formes + Photo ===== */}
<div className={Styles.leftSection}>
{/* Trapèze jaune */}
<div className={Styles.trapezoidYellow}></div>
{/* Trapèze bleu foncé */}
<div className={Styles.trapezoidDark}></div>
{/* Photo hexagonale */}
<div className={Styles.photoContainer}>
<div className={Styles.hexagonBorder}>
<div className={Styles.hexagonInner}>
{data.photoUrl && (
<img src={data.photoUrl} alt={fullName} className={Styles.photo} />
)}
</div>
</div>
</div>
</div>
{/* ===== PARTIE CENTRALE - Infos ===== */}
<div className={Styles.centerSection}>
{/* Triangle décoratif */}
<div className={Styles.triangleTop}></div>
{/* Nom */}
{fullName && <div className={Styles.name}>{fullName}</div>}
{/* Poste */}
{data.jobTitle && <div className={Styles.jobTitle}>{data.jobTitle}</div>}
{/* Barre de séparation verticale */}
<div className={Styles.separator}></div>
{/* Contacts en grille 2 colonnes */}
<div className={Styles.contactGrid}>
{/* Téléphone */}
{data.phone && (
<div className={Styles.contactItem}>
<span className={Styles.contactIcon}>
<svg viewBox="0 0 24 24"><path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"/></svg>
</span>
{data.phone}
</div>
)}
{/* Mobile */}
{data.mobile && (
<div className={Styles.contactItem}>
<span className={Styles.contactIcon}>
<svg viewBox="0 0 24 24"><path d="M15.5 1h-8C6.12 1 5 2.12 5 3.5v17C5 21.88 6.12 23 7.5 23h8c1.38 0 2.5-1.12 2.5-2.5v-17C18 2.12 16.88 1 15.5 1zm-4 21c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4.5-4H7V4h9v14z"/></svg>
</span>
{data.mobile}
</div>
)}
{/* Email */}
{data.email && (
<div className={Styles.contactItem}>
<span className={Styles.contactIcon}>
<svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
</span>
<a href={`mailto:${data.email}`} className={Styles.contactLink}>{data.email}</a>
</div>
)}
{/* Site web */}
{data.website && (
<div className={Styles.contactItem}>
<span className={Styles.contactIcon}>
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
</span>
<a href={data.website.startsWith("http") ? data.website : `https://${data.website}`} className={Styles.contactLink}>{data.website}</a>
</div>
)}
{/* Adresse */}
{data.address && (
<div className={`${Styles.contactItem} ${Styles.address}`}>
<span className={Styles.contactIcon}>
<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
</span>
{data.address}
</div>
)}
</div>
</div>
{/* ===== PARTIE DROITE - Logo + Social ===== */}
<div className={Styles.rightSection}>
{/* Triangle décoratif haut */}
<div className={Styles.triangleTopRight}></div>
{/* Logo */}
{data.logoUrl && (
<img src={data.logoUrl} alt={data.company || "Logo"} className={Styles.logo} />
)}
{/* Réseaux sociaux */}
<div className={Styles.socialIcons}>
{data.facebook && (
<a href={data.facebook} className={Styles.socialIcon}>
<svg viewBox="0 0 24 24"><path d="M18 2h-3a5 5 0 00-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 011-1h3z"/></svg>
</a>
)}
{data.instagram && (
<a href={data.instagram} className={Styles.socialIcon}>
<svg viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20" rx="5" fill="none" stroke="white" strokeWidth="2"/><circle cx="12" cy="12" r="4" fill="none" stroke="white" strokeWidth="2"/><circle cx="18" cy="6" r="1.5"/></svg>
</a>
)}
{data.twitter && (
<a href={data.twitter} className={Styles.socialIcon}>
<svg viewBox="0 0 24 24"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</a>
)}
{data.linkedin && (
<a href={data.linkedin} className={Styles.socialIcon}>
<svg viewBox="0 0 24 24"><path d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6zM2 9h4v12H2zM4 6a2 2 0 100-4 2 2 0 000 4z"/></svg>
</a>
)}
</div>
{/* Triangle décoratif bas */}
<div className={Styles.triangleBottomRight}></div>
</div>
</div>
{/* ========== BANNIÈRE PROMO ========== */}
{bannerUrl && (
<div className={Styles.banner}>
{bannerLink ? (
<a href={bannerLink}>
<img src={bannerUrl} alt="Promotion" />
</a>
) : (
<img src={bannerUrl} alt="Promotion" />
)}
</div>
)}
</div>
);
}
);
SignaturePreview.displayName = "SignaturePreview";
export default SignaturePreview;

View File

@ -0,0 +1,160 @@
'use client'
import { SignatureStyle, colorThemes } from '@/lib/types'
interface StyleSelectorProps {
currentStyle: SignatureStyle
currentThemeIndex: number
onStyleChange: (style: SignatureStyle) => void
onThemeChange: (themeIndex: number) => void
photoShape: 'circle' | 'hexagon'
onPhotoShapeChange: (shape: 'circle' | 'hexagon') => void
}
const styles: { id: SignatureStyle; name: string; description: string }[] = [
{ id: 'geometric', name: 'Géométrique', description: 'Formes triangulaires modernes' },
{ id: 'minimal', name: 'Minimal', description: 'Design épuré et simple' },
{ id: 'corporate', name: 'Corporate', description: 'Style professionnel classique' },
{ id: 'creative', name: 'Créatif', description: 'Design audacieux et coloré' },
]
export default function StyleSelector({
currentStyle,
currentThemeIndex,
onStyleChange,
onThemeChange,
photoShape,
onPhotoShapeChange,
}: StyleSelectorProps) {
return (
<div className="style-selector">
{/* Style Templates */}
<div className="style-selector__section">
<h4 className="style-selector__label">Style de signature</h4>
<div className="style-selector__grid">
{styles.map((style) => (
<button
key={style.id}
className={`style-card ${currentStyle === style.id ? 'style-card--active' : ''}`}
onClick={() => onStyleChange(style.id)}
>
<div className="style-card__preview">
<StylePreviewIcon style={style.id} />
</div>
<div className="style-card__info">
<span className="style-card__name">{style.name}</span>
<span className="style-card__desc">{style.description}</span>
</div>
</button>
))}
</div>
</div>
{/* Color Themes */}
<div className="style-selector__section">
<h4 className="style-selector__label">Thème de couleurs</h4>
<div className="style-selector__colors">
{colorThemes.map((theme, index) => (
<button
key={theme.name}
className={`color-theme ${currentThemeIndex === index ? 'color-theme--active' : ''}`}
onClick={() => onThemeChange(index)}
title={theme.name}
>
<div className="color-theme__preview">
<span style={{ backgroundColor: theme.secondary }} />
<span style={{ backgroundColor: theme.primary }} />
<span style={{ backgroundColor: theme.accent }} />
</div>
<span className="color-theme__name">{theme.name}</span>
</button>
))}
</div>
</div>
{/* Photo Shape */}
<div className="style-selector__section">
<h4 className="style-selector__label">Forme de la photo</h4>
<div className="style-selector__shapes">
<button
className={`shape-btn ${photoShape === 'hexagon' ? 'shape-btn--active' : ''}`}
onClick={() => onPhotoShapeChange('hexagon')}
>
<svg width="40" height="40" viewBox="0 0 40 40">
<polygon
points="20,2 37,11 37,29 20,38 3,29 3,11"
fill="currentColor"
opacity="0.2"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
<span>Hexagone</span>
</button>
<button
className={`shape-btn ${photoShape === 'circle' ? 'shape-btn--active' : ''}`}
onClick={() => onPhotoShapeChange('circle')}
>
<svg width="40" height="40" viewBox="0 0 40 40">
<circle
cx="20"
cy="20"
r="17"
fill="currentColor"
opacity="0.2"
stroke="currentColor"
strokeWidth="2"
/>
</svg>
<span>Cercle</span>
</button>
</div>
</div>
</div>
)
}
// Mini preview icons for each style
function StylePreviewIcon({ style }: { style: SignatureStyle }) {
switch (style) {
case 'geometric':
return (
<svg width="60" height="40" viewBox="0 0 60 40">
<polygon points="0,40 20,0 40,40" fill="#F5A623" opacity="0.8" />
<polygon points="20,40 40,0 60,40" fill="#1a1a2e" opacity="0.8" />
<circle cx="30" cy="25" r="8" fill="#fff" stroke="#ccc" />
</svg>
)
case 'minimal':
return (
<svg width="60" height="40" viewBox="0 0 60 40">
<rect x="5" y="10" width="50" height="1" fill="#ccc" />
<circle cx="15" cy="25" r="8" fill="#e0e0e0" />
<rect x="28" y="20" width="25" height="3" fill="#333" />
<rect x="28" y="26" width="20" height="2" fill="#999" />
</svg>
)
case 'corporate':
return (
<svg width="60" height="40" viewBox="0 0 60 40">
<rect x="0" y="0" width="60" height="40" fill="#f5f5f5" />
<rect x="0" y="0" width="4" height="40" fill="#0077B6" />
<circle cx="20" cy="20" r="10" fill="#e0e0e0" />
<rect x="35" y="12" width="20" height="4" fill="#333" />
<rect x="35" y="20" width="15" height="3" fill="#666" />
<rect x="35" y="26" width="18" height="2" fill="#999" />
</svg>
)
case 'creative':
return (
<svg width="60" height="40" viewBox="0 0 60 40">
<rect x="0" y="0" width="60" height="40" rx="5" fill="#7209B7" opacity="0.1" />
<circle cx="15" cy="20" r="12" fill="#7209B7" opacity="0.3" />
<circle cx="15" cy="20" r="8" fill="#fff" />
<rect x="30" y="10" width="25" height="5" rx="2" fill="#7209B7" />
<rect x="30" y="18" width="20" height="3" rx="1" fill="#666" />
<rect x="30" y="24" width="22" height="3" rx="1" fill="#4CC9F0" />
</svg>
)
}
}

100
src/hooks/useBanners.ts Normal file
View File

@ -0,0 +1,100 @@
'use client'
import { useState, useEffect } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Banner } from '@/lib/types'
export function useBanners() {
const [banners, setBanners] = useState<Banner[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const supabase = createClient()
// Charger les bannières initiales
const fetchBanners = async () => {
try {
const { data, error } = await supabase
.from('banners')
.select('*')
.eq('is_active', true)
.order('order', { ascending: true })
if (error) throw error
setBanners(data || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur lors du chargement des bannières')
} finally {
setLoading(false)
}
}
fetchBanners()
// Écouter les changements en temps réel
const channel = supabase
.channel('banners-changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'banners',
},
() => {
// Recharger les bannières quand il y a un changement
fetchBanners()
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [])
return { banners, loading, error }
}
// Hook pour vérifier si l'utilisateur est admin
export function useIsAdmin() {
const [isAdmin, setIsAdmin] = useState(false)
const [loading, setLoading] = useState(true)
useEffect(() => {
const supabase = createClient()
const checkAdmin = async () => {
try {
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
setIsAdmin(false)
return
}
const { data, error } = await supabase
.from('admins')
.select('id')
.eq('user_id', user.id)
.single()
if (error && error.code !== 'PGRST116') {
console.error('Erreur vérification admin:', error)
}
setIsAdmin(!!data)
} catch (err) {
console.error('Erreur:', err)
setIsAdmin(false)
} finally {
setLoading(false)
}
}
checkAdmin()
}, [])
return { isAdmin, loading }
}

182
src/hooks/useSignature.ts Normal file
View File

@ -0,0 +1,182 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { SignatureData, defaultSignatureData } from '@/lib/types'
// Convertir les données DB vers le format de l'app
function dbToSignature(data: Record<string, unknown>): SignatureData {
return {
firstName: (data.first_name as string) || '',
lastName: (data.last_name as string) || '',
jobTitle: (data.job_title as string) || '',
company: (data.company as string) || '',
email: (data.email as string) || '',
phone: (data.phone as string) || '',
mobile: (data.mobile as string) || '',
website: (data.website as string) || '',
address: (data.address as string) || '',
photoUrl: (data.photo_url as string) || '',
logoUrl: (data.logo_url as string) || '',
linkedin: (data.linkedin as string) || '',
twitter: (data.twitter as string) || '',
facebook: (data.facebook as string) || '',
instagram: (data.instagram as string) || '',
primaryColor: (data.primary_color as string) || '#F5A623',
secondaryColor: (data.secondary_color as string) || '#1a1a2e',
accentColor: (data.accent_color as string) || '#F5A623',
photoShape: (data.photo_shape as 'circle' | 'hexagon') || 'hexagon',
styleTemplate: (data.style_template as 'geometric' | 'minimal' | 'corporate' | 'creative') || 'geometric',
}
}
// Convertir les données de l'app vers le format DB
function signatureToDb(data: SignatureData, userId: string) {
return {
user_id: userId,
first_name: data.firstName,
last_name: data.lastName,
job_title: data.jobTitle,
company: data.company,
email: data.email,
phone: data.phone,
mobile: data.mobile,
website: data.website,
address: data.address,
photo_url: data.photoUrl,
logo_url: data.logoUrl,
linkedin: data.linkedin,
twitter: data.twitter,
facebook: data.facebook,
instagram: data.instagram,
primary_color: data.primaryColor,
secondary_color: data.secondaryColor,
accent_color: data.accentColor,
photo_shape: data.photoShape,
style_template: data.styleTemplate,
}
}
export function useSignature() {
const [signature, setSignature] = useState<SignatureData>(defaultSignatureData)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [userId, setUserId] = useState<string | null>(null)
// Charger la signature au démarrage
useEffect(() => {
const loadSignature = async () => {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
setLoading(false)
return
}
setUserId(user.id)
const { data, error } = await supabase
.from('user_signatures')
.select('*')
.eq('user_id', user.id)
.single()
if (data && !error) {
setSignature(dbToSignature(data))
}
setLoading(false)
}
loadSignature()
}, [])
// Sauvegarder la signature
const saveSignature = useCallback(async (data: SignatureData) => {
if (!userId) return { success: false, error: 'Non connecté' }
setSaving(true)
const supabase = createClient()
const dbData = signatureToDb(data, userId)
// Upsert (insert ou update si existe)
const { error } = await supabase
.from('user_signatures')
.upsert(dbData, { onConflict: 'user_id' })
setSaving(false)
if (error) {
return { success: false, error: error.message }
}
setSignature(data)
return { success: true, error: null }
}, [userId])
return {
signature,
setSignature,
saveSignature,
loading,
saving,
userId,
}
}
// Hook pour l'admin - charger toutes les signatures
export function useAllSignatures() {
const [signatures, setSignatures] = useState<Array<SignatureData & { id: string; user_id: string }>>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const loadSignatures = async () => {
const supabase = createClient()
const { data, error } = await supabase
.from('user_signatures')
.select('*')
.order('created_at', { ascending: false })
if (error) {
console.error('Erreur chargement signatures:', error)
}
if (data) {
setSignatures(data.map(d => ({
...dbToSignature(d),
id: d.id,
user_id: d.user_id,
})))
}
setLoading(false)
}
loadSignatures()
}, [])
// Modifier une signature (admin)
const updateSignature = async (userId: string, data: SignatureData) => {
const supabase = createClient()
const dbData = signatureToDb(data, userId)
const { error } = await supabase
.from('user_signatures')
.update(dbData)
.eq('user_id', userId)
if (!error) {
setSignatures(prev =>
prev.map(s => s.user_id === userId ? { ...data, id: s.id, user_id: userId } : s)
)
}
return { success: !error, error: error?.message }
}
return { signatures, loading, updateSignature }
}

282
src/hooks/useSignatures.ts Normal file
View File

@ -0,0 +1,282 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { createClient } from '@/lib/supabase/client'
import { SignatureData, defaultSignatureData } from '@/lib/types'
export interface SavedSignature extends SignatureData {
id: string
createdAt: string
updatedAt: string
}
// Convertir DB -> App
function dbToSignature(data: Record<string, unknown>): SavedSignature {
return {
id: data.id as string,
firstName: (data.first_name as string) || '',
lastName: (data.last_name as string) || '',
jobTitle: (data.job_title as string) || '',
company: (data.company as string) || '',
email: (data.email as string) || '',
phone: (data.phone as string) || '',
mobile: (data.mobile as string) || '',
website: (data.website as string) || '',
address: (data.address as string) || '',
photoUrl: (data.photo_url as string) || '',
logoUrl: (data.logo_url as string) || '',
linkedin: (data.linkedin as string) || '',
twitter: (data.twitter as string) || '',
facebook: (data.facebook as string) || '',
instagram: (data.instagram as string) || '',
primaryColor: (data.primary_color as string) || '#F5A623',
secondaryColor: (data.secondary_color as string) || '#1a1a2e',
accentColor: (data.accent_color as string) || '#F5A623',
photoShape: (data.photo_shape as 'circle' | 'hexagon') || 'hexagon',
styleTemplate: (data.style_template as 'geometric' | 'minimal' | 'corporate' | 'creative') || 'geometric',
bannerUrl: (data.banner_url as string) || '',
bannerLink: (data.banner_link as string) || '',
createdAt: data.created_at as string,
updatedAt: data.updated_at as string,
}
}
// Convertir App -> DB
function signatureToDb(data: SignatureData, userId: string) {
return {
created_by: userId,
first_name: data.firstName,
last_name: data.lastName,
job_title: data.jobTitle,
company: data.company,
email: data.email,
phone: data.phone,
mobile: data.mobile,
website: data.website,
address: data.address,
photo_url: data.photoUrl,
logo_url: data.logoUrl,
linkedin: data.linkedin,
twitter: data.twitter,
facebook: data.facebook,
instagram: data.instagram,
primary_color: data.primaryColor,
secondary_color: data.secondaryColor,
accent_color: data.accentColor,
photo_shape: data.photoShape,
style_template: data.styleTemplate,
banner_url: data.bannerUrl || '',
banner_link: data.bannerLink || '',
}
}
export function useSignatures() {
const [signatures, setSignatures] = useState<SavedSignature[]>([])
const [currentSignature, setCurrentSignature] = useState<SignatureData>(defaultSignatureData)
const [currentId, setCurrentId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [userId, setUserId] = useState<string | null>(null)
// Pour le debounce
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const pendingChangesRef = useRef<SignatureData | null>(null)
const lastSavedRef = useRef<string>('')
// Charger les signatures
useEffect(() => {
const load = async () => {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
setLoading(false)
return
}
setUserId(user.id)
const { data, error } = await supabase
.from('signatures')
.select('*')
.eq('created_by', user.id)
.order('updated_at', { ascending: false })
if (!error && data && data.length > 0) {
const loaded = data.map(dbToSignature)
setSignatures(loaded)
setCurrentSignature(loaded[0])
setCurrentId(loaded[0].id)
lastSavedRef.current = JSON.stringify(loaded[0])
}
setLoading(false)
}
load()
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
}
}, [])
// Sauvegarder en DB
const saveToDb = useCallback(async (data: SignatureData, id: string | null) => {
if (!userId) return false
const supabase = createClient()
const dbData = signatureToDb(data, userId)
if (id) {
const { error } = await supabase
.from('signatures')
.update(dbData)
.eq('id', id)
return !error
} else {
const { data: result, error } = await supabase
.from('signatures')
.insert(dbData)
.select()
.single()
if (!error && result) {
setCurrentId(result.id)
return true
}
return false
}
}, [userId])
// Mettre à jour avec debounce (1.5s)
const updateSignature = useCallback((data: SignatureData) => {
setCurrentSignature(data)
pendingChangesRef.current = data
// Annuler le précédent timeout
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
// Nouveau timeout
saveTimeoutRef.current = setTimeout(async () => {
const dataToSave = pendingChangesRef.current
if (!dataToSave) return
const currentDataStr = JSON.stringify(dataToSave)
if (currentDataStr === lastSavedRef.current) return
setSaving(true)
const success = await saveToDb(dataToSave, currentId)
setSaving(false)
if (success) {
lastSavedRef.current = currentDataStr
pendingChangesRef.current = null
// Rafraîchir la liste
if (userId) {
const supabase = createClient()
const { data: refreshed } = await supabase
.from('signatures')
.select('*')
.eq('created_by', userId)
.order('updated_at', { ascending: false })
if (refreshed) {
setSignatures(refreshed.map(dbToSignature))
}
}
}
}, 1500)
}, [currentId, saveToDb, userId])
// Créer nouvelle signature
const createNewSignature = useCallback(async () => {
if (!userId) return null
const newData: SignatureData = {
...defaultSignatureData,
firstName: 'Nouveau',
lastName: 'Contact',
}
setSaving(true)
const supabase = createClient()
const dbData = signatureToDb(newData, userId)
const { data, error } = await supabase
.from('signatures')
.insert(dbData)
.select()
.single()
setSaving(false)
if (!error && data) {
const created = dbToSignature(data)
setSignatures(prev => [created, ...prev])
setCurrentSignature(created)
setCurrentId(created.id)
lastSavedRef.current = JSON.stringify(created)
return created
}
return null
}, [userId])
// Sélectionner une signature
const selectSignature = useCallback((sig: SavedSignature) => {
// Annuler les sauvegardes en attente
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current)
}
pendingChangesRef.current = null
setCurrentSignature(sig)
setCurrentId(sig.id)
lastSavedRef.current = JSON.stringify(sig)
}, [])
// Supprimer une signature
const deleteSignature = useCallback(async (id: string) => {
if (!userId) return false
const supabase = createClient()
const { error } = await supabase
.from('signatures')
.delete()
.eq('id', id)
if (error) return false
const remaining = signatures.filter(s => s.id !== id)
setSignatures(remaining)
if (currentId === id) {
if (remaining.length > 0) {
selectSignature(remaining[0])
} else {
setCurrentSignature(defaultSignatureData)
setCurrentId(null)
lastSavedRef.current = ''
}
}
return true
}, [userId, currentId, signatures, selectSignature])
return {
signatures,
currentSignature,
currentId,
loading,
saving,
userId,
updateSignature,
createNewSignature,
selectSignature,
deleteSignature,
}
}

244
src/lib/export.ts Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,8 @@
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}

View File

@ -0,0 +1,55 @@
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
})
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
supabaseResponse = NextResponse.next({
request,
})
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
const {
data: { user },
} = await supabase.auth.getUser()
// Redirect to login if not authenticated and trying to access dashboard
if (
!user &&
request.nextUrl.pathname.startsWith('/dashboard')
) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
// Redirect to dashboard if authenticated and trying to access login/register
if (
user &&
(request.nextUrl.pathname === '/login' || request.nextUrl.pathname === '/register')
) {
const url = request.nextUrl.clone()
url.pathname = '/dashboard'
return NextResponse.redirect(url)
}
return supabaseResponse
}

View File

@ -0,0 +1,28 @@
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing user sessions.
}
},
},
}
)
}

93
src/lib/types.ts Normal file

File diff suppressed because one or more lines are too long

70
src/lib/upload.ts Normal file
View File

@ -0,0 +1,70 @@
import { createClient } from '@/lib/supabase/client'
export interface UploadResult {
success: boolean
url?: string
error?: string
}
/**
* Upload une image vers Supabase Storage
* @param file - Le fichier à uploader
* @param folder - Le dossier de destination (ex: 'banners', 'photos', 'logos')
* @returns L'URL publique de l'image
*/
export async function uploadImage(file: File, folder: string = 'banners'): Promise<UploadResult> {
try {
const supabase = createClient()
// Générer un nom unique pour le fichier
const fileExt = file.name.split('.').pop()
const fileName = `${folder}/${Date.now()}-${Math.random().toString(36).substring(7)}.${fileExt}`
// Upload vers Supabase Storage
const { data, error } = await supabase.storage
.from('images')
.upload(fileName, file, {
cacheControl: '3600',
upsert: false
})
if (error) {
console.error('Upload error:', error)
return { success: false, error: error.message }
}
// Obtenir l'URL publique
const { data: urlData } = supabase.storage
.from('images')
.getPublicUrl(data.path)
return { success: true, url: urlData.publicUrl }
} catch (err) {
console.error('Upload exception:', err)
return { success: false, error: 'Erreur lors de l\'upload' }
}
}
/**
* Supprime une image de Supabase Storage
* @param url - L'URL publique de l'image à supprimer
*/
export async function deleteImage(url: string): Promise<boolean> {
try {
const supabase = createClient()
// Extraire le chemin du fichier depuis l'URL
const urlObj = new URL(url)
const path = urlObj.pathname.split('/images/')[1]
if (!path) return false
const { error } = await supabase.storage
.from('images')
.remove([path])
return !error
} catch {
return false
}
}

12
src/middleware.ts Normal file
View File

@ -0,0 +1,12 @@
import { type NextRequest } from 'next/server'
import { updateSession } from '@/lib/supabase/middleware'
export async function middleware(request: NextRequest) {
return await updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}

323
src/styles/admin.scss Normal file
View File

@ -0,0 +1,323 @@
@use 'variables' as *;
.admin {
min-height: 100vh;
background-color: $gray-50;
&__loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
font-size: $font-size-lg;
color: $gray-500;
}
&__header {
background-color: $white;
border-bottom: 1px solid $gray-200;
padding: $spacing-4 $spacing-6;
display: flex;
align-items: center;
justify-content: space-between;
h1 {
font-size: $font-size-xl;
font-weight: 600;
color: $gray-900;
margin: 0;
}
}
&__main {
max-width: 1000px;
margin: 0 auto;
padding: $spacing-6;
}
&__section {
background-color: $white;
border-radius: $radius-xl;
box-shadow: $shadow-md;
padding: $spacing-6;
margin-bottom: $spacing-6;
h2 {
font-size: $font-size-lg;
font-weight: 600;
color: $gray-900;
margin: 0 0 $spacing-4 0;
padding-bottom: $spacing-3;
border-bottom: 1px solid $gray-200;
}
h3 {
font-size: $font-size-base;
font-weight: 600;
color: $gray-700;
margin: 0 0 $spacing-2 0;
}
&--info {
background-color: #eff6ff;
border: 1px solid #bfdbfe;
p {
margin: 0;
color: #1e40af;
font-size: $font-size-sm;
line-height: 1.6;
}
}
}
&__form {
display: flex;
flex-direction: column;
gap: $spacing-4;
}
&__form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $spacing-4;
@media (max-width: $breakpoint-md) {
grid-template-columns: 1fr;
}
}
&__field {
display: flex;
flex-direction: column;
gap: $spacing-2;
label {
font-size: $font-size-sm;
font-weight: 500;
color: $gray-700;
}
&--btn {
justify-content: flex-end;
}
}
&__preview {
margin-top: $spacing-2;
padding: $spacing-4;
background-color: $gray-100;
border-radius: $radius-lg;
p {
margin: 0 0 $spacing-2 0;
font-size: $font-size-sm;
color: $gray-600;
}
img {
max-width: 100%;
height: auto;
border-radius: $radius-md;
}
}
&__empty {
text-align: center;
color: $gray-500;
padding: $spacing-8;
}
&__banners {
display: flex;
flex-direction: column;
gap: $spacing-4;
}
&__banner {
display: grid;
grid-template-columns: 200px 1fr auto;
gap: $spacing-4;
padding: $spacing-4;
background-color: $gray-50;
border-radius: $radius-lg;
border: 1px solid $gray-200;
&--inactive {
opacity: 0.6;
background-color: $gray-100;
}
@media (max-width: $breakpoint-md) {
grid-template-columns: 1fr;
}
}
&__banner-image {
position: relative;
img {
width: 100%;
height: auto;
border-radius: $radius-md;
}
}
&__badge {
position: absolute;
top: $spacing-2;
left: $spacing-2;
background-color: $error;
color: $white;
font-size: $font-size-xs;
padding: 2px 8px;
border-radius: $radius-sm;
}
&__banner-details {
display: flex;
flex-direction: column;
gap: $spacing-3;
}
&__banner-field {
display: flex;
flex-direction: column;
gap: $spacing-1;
label {
font-size: $font-size-xs;
font-weight: 500;
color: $gray-500;
text-transform: uppercase;
}
.input {
font-size: $font-size-sm;
padding: $spacing-2;
}
}
&__banner-actions {
display: flex;
flex-direction: column;
gap: $spacing-2;
justify-content: center;
.btn {
white-space: nowrap;
}
}
}
// Bouton danger
.btn--danger {
background-color: $error;
color: $white;
border: none;
&:hover {
background-color: darken($error, 10%);
}
}
// Bouton small
.btn--sm {
padding: $spacing-2 $spacing-3;
font-size: $font-size-sm;
}
// Tabs
.admin__tabs {
display: flex;
gap: $spacing-2;
max-width: 1000px;
margin: 0 auto;
padding: 0 $spacing-6;
}
.admin__tab {
padding: $spacing-3 $spacing-6;
background-color: $white;
border: 1px solid $gray-200;
border-bottom: none;
border-radius: $radius-lg $radius-lg 0 0;
font-size: $font-size-sm;
font-weight: 500;
color: $gray-600;
cursor: pointer;
transition: all $transition-fast;
&:hover {
background-color: $gray-50;
color: $gray-900;
}
&--active {
background-color: $white;
color: $primary;
border-color: $gray-200;
position: relative;
&::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background-color: $white;
}
}
}
// Users list
.admin__users {
display: flex;
flex-direction: column;
gap: $spacing-3;
}
.admin__user {
padding: $spacing-4;
background-color: $gray-50;
border-radius: $radius-lg;
border: 1px solid $gray-200;
}
.admin__user-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.admin__user-info {
display: flex;
align-items: center;
gap: $spacing-3;
h3 {
margin: 0;
font-size: $font-size-base;
font-weight: 600;
color: $gray-900;
}
p {
margin: 0;
font-size: $font-size-sm;
color: $gray-600;
}
}
.admin__user-photo {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
.admin__user-job {
font-size: $font-size-xs !important;
color: $gray-500 !important;
}

92
src/styles/auth.scss Normal file
View File

@ -0,0 +1,92 @@
@use 'variables' as *;
.auth {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-4;
&__container {
width: 100%;
max-width: 420px;
}
&__card {
background-color: $white;
border-radius: $radius-2xl;
box-shadow: $shadow-xl;
padding: $spacing-8;
}
&__header {
text-align: center;
margin-bottom: $spacing-8;
}
&__logo {
font-size: $font-size-2xl;
font-weight: 700;
color: $primary;
margin-bottom: $spacing-2;
}
&__title {
font-size: $font-size-xl;
font-weight: 600;
color: $gray-900;
margin-bottom: $spacing-2;
}
&__subtitle {
font-size: $font-size-sm;
color: $gray-500;
}
&__form {
display: flex;
flex-direction: column;
gap: $spacing-4;
}
&__field {
display: flex;
flex-direction: column;
}
&__submit {
margin-top: $spacing-4;
width: 100%;
}
&__footer {
text-align: center;
margin-top: $spacing-6;
padding-top: $spacing-6;
border-top: 1px solid $gray-200;
font-size: $font-size-sm;
color: $gray-600;
a {
font-weight: 500;
}
}
&__error {
background-color: rgba($error, 0.1);
border: 1px solid rgba($error, 0.2);
border-radius: $radius-lg;
padding: $spacing-3 $spacing-4;
color: $error;
font-size: $font-size-sm;
}
&__success {
background-color: rgba($success, 0.1);
border: 1px solid rgba($success, 0.2);
border-radius: $radius-lg;
padding: $spacing-3 $spacing-4;
color: #1a9a4a;
font-size: $font-size-sm;
}
}

861
src/styles/dashboard.scss Normal file
View File

@ -0,0 +1,861 @@
@use 'variables' as *;
@use 'sass:color';
// ============================================
// BASE LAYOUT
// ============================================
.dashboard {
min-height: 100vh;
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}
// Loading Screen
.loading-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 1rem;
color: $gray-500;
&__spinner {
width: 32px;
height: 32px;
border: 3px solid $gray-200;
border-top-color: $primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
}
@keyframes spin {
to { transform: rotate(360deg); }
}
// ============================================
// HEADER
// ============================================
.header {
background: $white;
border-bottom: 1px solid $gray-200;
position: sticky;
top: 0;
z-index: 100;
&__inner {
max-width: 1600px;
margin: 0 auto;
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
&__brand {
font-size: 1.125rem;
font-weight: 700;
color: $gray-900;
letter-spacing: -0.02em;
}
&__actions {
display: flex;
align-items: center;
gap: 1rem;
}
&__email {
font-size: 0.875rem;
color: $gray-500;
}
}
.auto-save {
font-size: 0.75rem;
color: $primary;
background: rgba($primary, 0.1);
padding: 0.25rem 0.75rem;
border-radius: 999px;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
// ============================================
// APP LAYOUT
// ============================================
.app-layout {
display: grid;
grid-template-columns: 260px 1fr;
max-width: 1600px;
margin: 0 auto;
gap: 1.5rem;
padding: 1.5rem;
min-height: calc(100vh - 60px);
@media (max-width: 1024px) {
grid-template-columns: 1fr;
}
}
// ============================================
// SIDEBAR
// ============================================
.sidebar {
background: $white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
padding: 1rem;
height: fit-content;
position: sticky;
top: 80px;
&__top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid $gray-100;
h2 {
font-size: 0.875rem;
font-weight: 600;
color: $gray-900;
margin: 0;
}
}
}
.signature-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 60vh;
overflow-y: auto;
&__empty {
text-align: center;
padding: 2rem 1rem;
color: $gray-500;
p {
font-size: 0.875rem;
margin-bottom: 1rem;
}
}
}
.signature-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
position: relative;
&:hover {
background: $gray-50;
.signature-card__delete {
opacity: 1;
}
}
&--active {
background: rgba($primary, 0.08);
border-color: rgba($primary, 0.2);
.signature-card__avatar {
background: $primary;
color: $white;
}
.signature-card__name {
color: $primary;
}
}
&__avatar {
width: 36px;
height: 36px;
border-radius: 8px;
background: $gray-100;
color: $gray-600;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
flex-shrink: 0;
text-transform: uppercase;
}
&__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
&__name {
font-size: 0.8125rem;
font-weight: 600;
color: $gray-900;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__role {
font-size: 0.6875rem;
color: $gray-500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__delete {
width: 28px;
height: 28px;
border-radius: 6px;
background: transparent;
border: none;
color: $gray-400;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: all 0.15s ease;
flex-shrink: 0;
&:hover {
background: #fee2e2;
color: #dc2626;
}
}
}
// ============================================
// MAIN CONTENT
// ============================================
.main-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
// ============================================
// TABS
// ============================================
.tabs {
display: flex;
gap: 0.25rem;
background: $white;
padding: 0.25rem;
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
width: fit-content;
&__item {
padding: 0.5rem 1rem;
font-size: 0.8125rem;
font-weight: 500;
color: $gray-600;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
color: $gray-900;
background: $gray-50;
}
&--active {
background: $primary;
color: $white;
&:hover {
background: $primary;
color: $white;
}
}
}
}
// ============================================
// CONTENT GRID
// ============================================
.content-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
@media (max-width: 1200px) {
grid-template-columns: 1fr;
}
}
// ============================================
// PANEL
// ============================================
.panel {
background: $white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
padding: 1.5rem;
&__title {
font-size: 0.875rem;
font-weight: 600;
color: $gray-900;
margin: 0 0 1rem 0;
padding-bottom: 0.75rem;
border-bottom: 1px solid $gray-100;
}
&--preview {
background: linear-gradient(180deg, $white 0%, #fafafa 100%);
}
}
// ============================================
// PREVIEW BOX
// ============================================
.preview-box {
background: $white;
border: 1px solid $gray-200;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.export-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.preview-hint {
margin-top: 1rem;
font-size: 0.75rem;
color: $gray-500;
text-align: center;
}
// ============================================
// EDITOR
// ============================================
.editor {
display: flex;
flex-direction: column;
gap: 1rem;
&__group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
&__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
&__divider {
height: 1px;
background: $gray-100;
margin: 0.5rem 0;
}
&__subtitle {
font-size: 0.6875rem;
font-weight: 600;
color: $gray-500;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
}
// ============================================
// BUTTONS
// ============================================
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 8px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&--primary {
background: $primary;
color: $white;
&:hover:not(:disabled) {
background: color.adjust($primary, $lightness: -8%);
}
}
&--secondary {
background: $gray-100;
color: $gray-700;
&:hover:not(:disabled) {
background: $gray-200;
}
}
&--ghost {
background: transparent;
color: $gray-600;
&:hover:not(:disabled) {
background: $gray-100;
color: $gray-900;
}
}
&--success {
background: #10b981;
color: $white;
}
&--icon {
width: 32px;
height: 32px;
padding: 0;
font-size: 1.125rem;
}
}
// ============================================
// FORMS
// ============================================
.label {
font-size: 0.75rem;
font-weight: 500;
color: $gray-700;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
color: $gray-900;
background: $white;
border: 1px solid $gray-200;
border-radius: 8px;
transition: all 0.15s ease;
&:hover {
border-color: $gray-300;
}
&:focus {
outline: none;
border-color: $primary;
box-shadow: 0 0 0 3px rgba($primary, 0.1);
}
&::placeholder {
color: $gray-400;
}
}
// ============================================
// BANNER UPLOAD
// ============================================
.banner-upload {
border: 2px dashed $gray-200;
border-radius: 8px;
padding: 1.5rem;
text-align: center;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: $primary;
background: rgba($primary, 0.02);
}
&--has-image {
border-style: solid;
padding: 0.75rem;
}
&__input {
display: none;
}
&__placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: $gray-500;
}
&__icon {
font-size: 1.5rem;
}
&__text {
font-size: 0.8125rem;
}
&__hint {
font-size: 0.6875rem;
color: $gray-400;
}
&__preview {
position: relative;
img {
max-width: 100%;
max-height: 100px;
border-radius: 6px;
}
}
&__remove {
position: absolute;
top: -8px;
right: -8px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #dc2626;
color: $white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
&:hover {
background: #b91c1c;
}
}
}
// ============================================
// STYLE SELECTOR
// ============================================
.style-selector {
display: flex;
flex-direction: column;
gap: 1.5rem;
&__section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
&__label {
font-size: 0.6875rem;
font-weight: 600;
color: $gray-500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
&__colors {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
&__shapes {
display: flex;
gap: 0.75rem;
}
}
.style-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid $gray-200;
border-radius: 8px;
background: $white;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: $gray-300;
}
&--active {
border-color: $primary;
background: rgba($primary, 0.04);
}
&__preview {
width: 48px;
height: 32px;
border-radius: 6px;
background: $gray-100;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__info {
display: flex;
flex-direction: column;
gap: 2px;
}
&__name {
font-size: 0.8125rem;
font-weight: 600;
color: $gray-900;
}
&__desc {
font-size: 0.6875rem;
color: $gray-500;
}
}
.color-theme {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
padding: 0.375rem;
border: 2px solid transparent;
border-radius: 8px;
background: transparent;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
border-color: $gray-200;
}
&--active {
border-color: $primary;
}
&__preview {
display: flex;
width: 40px;
height: 24px;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
span {
flex: 1;
}
}
&__name {
font-size: 0.625rem;
color: $gray-600;
white-space: nowrap;
}
}
.shape-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid $gray-200;
border-radius: 8px;
background: $white;
cursor: pointer;
transition: all 0.15s ease;
color: $gray-500;
&:hover {
border-color: $gray-300;
color: $gray-700;
}
&--active {
border-color: $primary;
background: rgba($primary, 0.04);
color: $primary;
}
span {
font-size: 0.6875rem;
font-weight: 500;
}
}
// ============================================
// BANNER GENERATOR
// ============================================
.banner-generator {
&__preview {
margin-bottom: 1rem;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&__section {
margin-bottom: 1rem;
}
&__label {
display: block;
font-size: 0.6875rem;
font-weight: 600;
color: $gray-500;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
&__templates {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
&__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
@media (max-width: 640px) {
grid-template-columns: 1fr;
}
}
&__actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
&__hint {
margin-top: 0.75rem;
font-size: 0.6875rem;
color: $gray-500;
text-align: center;
}
}
.banner-template {
width: 44px;
height: 28px;
border: 2px solid transparent;
border-radius: 6px;
cursor: pointer;
padding: 0;
overflow: hidden;
transition: all 0.15s ease;
&:hover {
transform: scale(1.05);
}
&--active {
border-color: $gray-900;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
&__preview {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 4px;
span {
width: 6px;
height: 6px;
border-radius: 50%;
}
}
}
// ============================================
// SIGNATURE PREVIEW STYLES
// ============================================
.preview {
&__container {
background: $gray-50;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
}
&__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
&__info {
margin-top: 1rem;
font-size: 0.6875rem;
color: $gray-500;
text-align: center;
font-style: italic;
}
}

135
src/styles/globals.scss Normal file
View File

@ -0,0 +1,135 @@
@use 'variables' as *;
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
font-family: $font-family;
background-color: $gray-50;
color: $gray-900;
}
a {
color: $primary;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
// Utility classes
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 $spacing-4;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $spacing-2;
padding: $spacing-3 $spacing-6;
font-size: $font-size-base;
font-weight: 500;
border-radius: $radius-lg;
border: none;
cursor: pointer;
transition: all $transition-fast;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&--primary {
background-color: $primary;
color: $white;
&:hover:not(:disabled) {
background-color: $primary-dark;
}
}
&--secondary {
background-color: $gray-200;
color: $gray-700;
&:hover:not(:disabled) {
background-color: $gray-300;
}
}
&--outline {
background-color: transparent;
border: 1px solid $gray-300;
color: $gray-700;
&:hover:not(:disabled) {
background-color: $gray-100;
}
}
&--success {
background-color: $success;
color: $white;
&:hover:not(:disabled) {
background-color: #1a9a4a;
}
}
}
.input {
width: 100%;
padding: $spacing-3 $spacing-4;
font-size: $font-size-base;
border: 1px solid $gray-300;
border-radius: $radius-lg;
background-color: $white;
transition: border-color $transition-fast, box-shadow $transition-fast;
&:focus {
outline: none;
border-color: $primary;
box-shadow: 0 0 0 3px rgba($primary, 0.1);
}
&::placeholder {
color: $gray-400;
}
}
.label {
display: block;
margin-bottom: $spacing-2;
font-size: $font-size-sm;
font-weight: 500;
color: $gray-700;
}
.card {
background-color: $white;
border-radius: $radius-xl;
box-shadow: $shadow-md;
padding: $spacing-6;
}
.error-message {
color: $error;
font-size: $font-size-sm;
margin-top: $spacing-2;
}
.success-message {
color: $success;
font-size: $font-size-sm;
margin-top: $spacing-2;
}

135
src/styles/home.scss Normal file
View File

@ -0,0 +1,135 @@
@use 'variables' as *;
.home {
min-height: 100vh;
display: flex;
flex-direction: column;
&__header {
background-color: $white;
border-bottom: 1px solid $gray-200;
padding: $spacing-4 0;
}
&__header-content {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1200px;
margin: 0 auto;
padding: 0 $spacing-6;
}
&__logo {
font-size: $font-size-xl;
font-weight: 700;
color: $primary;
}
&__nav {
display: flex;
gap: $spacing-3;
}
&__main {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-12 $spacing-6;
}
&__hero {
text-align: center;
max-width: 700px;
margin-bottom: $spacing-12;
}
&__title {
font-size: 3rem;
font-weight: 700;
color: $gray-900;
line-height: 1.2;
margin-bottom: $spacing-6;
@media (max-width: $breakpoint-md) {
font-size: 2rem;
}
}
&__title-accent {
color: $primary;
}
&__subtitle {
font-size: $font-size-lg;
color: $gray-600;
margin-bottom: $spacing-8;
line-height: 1.6;
}
&__cta {
display: flex;
justify-content: center;
}
&__features {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $spacing-8;
max-width: 1000px;
width: 100%;
@media (max-width: $breakpoint-md) {
grid-template-columns: 1fr;
gap: $spacing-6;
}
}
&__feature {
background-color: $white;
border-radius: $radius-xl;
padding: $spacing-8;
text-align: center;
box-shadow: $shadow-md;
transition: transform $transition-base, box-shadow $transition-base;
&:hover {
transform: translateY(-4px);
box-shadow: $shadow-lg;
}
}
&__feature-icon {
font-size: 2.5rem;
margin-bottom: $spacing-4;
}
&__feature-title {
font-size: $font-size-lg;
font-weight: 600;
color: $gray-900;
margin-bottom: $spacing-3;
}
&__feature-text {
font-size: $font-size-sm;
color: $gray-600;
line-height: 1.6;
}
&__footer {
background-color: $white;
border-top: 1px solid $gray-200;
padding: $spacing-6;
text-align: center;
color: $gray-500;
font-size: $font-size-sm;
}
}
.btn--large {
padding: $spacing-4 $spacing-8;
font-size: $font-size-lg;
}

65
src/styles/variables.scss Normal file
View File

@ -0,0 +1,65 @@
// Colors
$primary: #2563eb;
$primary-dark: #1d4ed8;
$secondary: #64748b;
$success: #22c55e;
$error: #ef4444;
$warning: #f59e0b;
$white: #ffffff;
$gray-50: #f8fafc;
$gray-100: #f1f5f9;
$gray-200: #e2e8f0;
$gray-300: #cbd5e1;
$gray-400: #94a3b8;
$gray-500: #64748b;
$gray-600: #475569;
$gray-700: #334155;
$gray-800: #1e293b;
$gray-900: #0f172a;
// Typography
$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
$font-size-xs: 0.75rem;
$font-size-sm: 0.875rem;
$font-size-base: 1rem;
$font-size-lg: 1.125rem;
$font-size-xl: 1.25rem;
$font-size-2xl: 1.5rem;
$font-size-3xl: 1.875rem;
// Spacing
$spacing-1: 0.25rem;
$spacing-2: 0.5rem;
$spacing-3: 0.75rem;
$spacing-4: 1rem;
$spacing-5: 1.25rem;
$spacing-6: 1.5rem;
$spacing-8: 2rem;
$spacing-10: 2.5rem;
$spacing-12: 3rem;
// Border Radius
$radius-sm: 0.25rem;
$radius-md: 0.375rem;
$radius-lg: 0.5rem;
$radius-xl: 0.75rem;
$radius-2xl: 1rem;
$radius-full: 9999px;
// Shadows
$shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
$shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
$shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
$shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
// Transitions
$transition-fast: 150ms ease;
$transition-base: 200ms ease;
$transition-slow: 300ms ease;
// Breakpoints
$breakpoint-sm: 640px;
$breakpoint-md: 768px;
$breakpoint-lg: 1024px;
$breakpoint-xl: 1280px;

View File

@ -0,0 +1,171 @@
-- Table pour les bannières du carousel (gérée par super admin)
CREATE TABLE IF NOT EXISTS banners (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
image_url TEXT NOT NULL,
link_url TEXT,
title TEXT,
"order" INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Table pour les super admins
CREATE TABLE IF NOT EXISTS admins (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Table pour les signatures des utilisateurs
CREATE TABLE IF NOT EXISTS user_signatures (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE UNIQUE,
email TEXT,
first_name TEXT,
last_name TEXT,
job_title TEXT,
company TEXT,
phone TEXT,
mobile TEXT,
website TEXT,
address TEXT,
photo_url TEXT,
logo_url TEXT,
linkedin TEXT,
twitter TEXT,
facebook TEXT,
instagram TEXT,
primary_color TEXT DEFAULT '#3498db',
secondary_color TEXT DEFAULT '#2c3e50',
accent_color TEXT DEFAULT '#5dade2',
photo_shape TEXT DEFAULT 'hexagon',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Index pour optimiser les requêtes
CREATE INDEX IF NOT EXISTS idx_banners_active ON banners(is_active, "order");
CREATE INDEX IF NOT EXISTS idx_admins_user_id ON admins(user_id);
CREATE INDEX IF NOT EXISTS idx_user_signatures_user_id ON user_signatures(user_id);
-- Fonction pour mettre à jour updated_at automatiquement
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger pour mettre à jour updated_at sur banners
DROP TRIGGER IF EXISTS banners_updated_at ON banners;
CREATE TRIGGER banners_updated_at
BEFORE UPDATE ON banners
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
-- Trigger pour mettre à jour updated_at sur user_signatures
DROP TRIGGER IF EXISTS user_signatures_updated_at ON user_signatures;
CREATE TRIGGER user_signatures_updated_at
BEFORE UPDATE ON user_signatures
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
-- RLS (Row Level Security)
ALTER TABLE banners ENABLE ROW LEVEL SECURITY;
ALTER TABLE admins ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_signatures ENABLE ROW LEVEL SECURITY;
-- Politique: Tout le monde peut lire les bannières actives
CREATE POLICY "Bannières actives visibles par tous" ON banners
FOR SELECT USING (is_active = true);
-- Politique: Les admins peuvent TOUT lire (même inactives)
CREATE POLICY "Admins peuvent tout lire" ON banners
FOR SELECT USING (
EXISTS (
SELECT 1 FROM admins WHERE user_id = auth.uid()
)
);
-- Politique: Les admins peuvent insérer
CREATE POLICY "Admins peuvent inserer" ON banners
FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM admins WHERE user_id = auth.uid()
)
);
-- Politique: Les admins peuvent modifier
CREATE POLICY "Admins peuvent modifier" ON banners
FOR UPDATE USING (
EXISTS (
SELECT 1 FROM admins WHERE user_id = auth.uid()
)
);
-- Politique: Les admins peuvent supprimer
CREATE POLICY "Admins peuvent supprimer" ON banners
FOR DELETE USING (
EXISTS (
SELECT 1 FROM admins WHERE user_id = auth.uid()
)
);
-- Politique: Un utilisateur peut vérifier s'il est admin (lecture de sa propre entrée)
CREATE POLICY "Users peuvent verifier leur statut admin" ON admins
FOR SELECT USING (user_id = auth.uid());
-- ==========================================
-- POLITIQUES POUR user_signatures
-- ==========================================
-- Utilisateurs peuvent lire leur propre signature
CREATE POLICY "Users peuvent lire leur signature" ON user_signatures
FOR SELECT USING (user_id = auth.uid());
-- Utilisateurs peuvent créer leur propre signature
CREATE POLICY "Users peuvent creer leur signature" ON user_signatures
FOR INSERT WITH CHECK (user_id = auth.uid());
-- Utilisateurs peuvent modifier leur propre signature
CREATE POLICY "Users peuvent modifier leur signature" ON user_signatures
FOR UPDATE USING (user_id = auth.uid());
-- Admins peuvent lire TOUTES les signatures
CREATE POLICY "Admins peuvent lire toutes signatures" ON user_signatures
FOR SELECT USING (
EXISTS (SELECT 1 FROM admins WHERE user_id = auth.uid())
);
-- Admins peuvent modifier TOUTES les signatures
CREATE POLICY "Admins peuvent modifier toutes signatures" ON user_signatures
FOR UPDATE USING (
EXISTS (SELECT 1 FROM admins WHERE user_id = auth.uid())
);
-- Admins peuvent créer des signatures pour n'importe qui
CREATE POLICY "Admins peuvent creer signatures" ON user_signatures
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM admins WHERE user_id = auth.uid())
);
-- Admins peuvent supprimer des signatures
CREATE POLICY "Admins peuvent supprimer signatures" ON user_signatures
FOR DELETE USING (
EXISTS (SELECT 1 FROM admins WHERE user_id = auth.uid())
);
-- Insérer l'admin principal
INSERT INTO admins (user_id, email) VALUES
('27f80c55-f045-4e6c-9751-56f5c86b71a2', 'admin@navier.net')
ON CONFLICT (email) DO NOTHING;
-- Insérer quelques bannières par défaut (à personnaliser)
INSERT INTO banners (image_url, link_url, title, "order", is_active) VALUES
('https://via.placeholder.com/600x150/3498db/ffffff?text=Promotion+1', 'https://example.com/promo1', 'Promotion 1', 1, true),
('https://via.placeholder.com/600x150/e74c3c/ffffff?text=Promotion+2', 'https://example.com/promo2', 'Promotion 2', 2, true),
('https://via.placeholder.com/600x150/2ecc71/ffffff?text=Promotion+3', 'https://example.com/promo3', 'Promotion 3', 3, true)
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,35 @@
-- Créer le bucket pour les images
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES (
'images',
'images',
true,
5242880, -- 5MB max
ARRAY['image/jpeg', 'image/png', 'image/gif', 'image/webp']
)
ON CONFLICT (id) DO NOTHING;
-- Politique: Tout le monde peut voir les images publiques
CREATE POLICY "Images publiques accessibles" ON storage.objects
FOR SELECT USING (bucket_id = 'images');
-- Politique: Utilisateurs authentifiés peuvent uploader
CREATE POLICY "Users authentifies peuvent uploader" ON storage.objects
FOR INSERT WITH CHECK (
bucket_id = 'images'
AND auth.role() = 'authenticated'
);
-- Politique: Admins peuvent tout faire sur les images
CREATE POLICY "Admins gestion complete images" ON storage.objects
FOR ALL USING (
bucket_id = 'images'
AND EXISTS (SELECT 1 FROM admins WHERE user_id = auth.uid())
);
-- Politique: Users peuvent supprimer leurs propres uploads
CREATE POLICY "Users peuvent supprimer leurs images" ON storage.objects
FOR DELETE USING (
bucket_id = 'images'
AND auth.uid()::text = (storage.foldername(name))[1]
);

View File

@ -0,0 +1,74 @@
-- Table pour stocker les signatures créées par l'admin
-- (différent de user_signatures qui est 1 signature par utilisateur)
CREATE TABLE IF NOT EXISTS signatures (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
created_by UUID REFERENCES auth.users(id) ON DELETE CASCADE,
-- Infos personnelles
first_name TEXT NOT NULL,
last_name TEXT,
job_title TEXT,
company TEXT DEFAULT 'Navier Instruments',
-- Contact
email TEXT,
phone TEXT,
mobile TEXT,
website TEXT DEFAULT 'www.navier-instruments.com',
address TEXT,
-- Images
photo_url TEXT,
logo_url TEXT DEFAULT '/preview.webp',
-- Réseaux sociaux
linkedin TEXT,
twitter TEXT,
facebook TEXT,
instagram TEXT,
-- Style
primary_color TEXT DEFAULT '#F5A623',
secondary_color TEXT DEFAULT '#1a1a2e',
accent_color TEXT DEFAULT '#F5A623',
photo_shape TEXT DEFAULT 'hexagon',
style_template TEXT DEFAULT 'geometric',
-- Bannière
banner_url TEXT,
banner_link TEXT,
-- Métadonnées
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Index
CREATE INDEX IF NOT EXISTS idx_signatures_created_by ON signatures(created_by);
CREATE INDEX IF NOT EXISTS idx_signatures_name ON signatures(first_name, last_name);
-- Trigger pour updated_at
DROP TRIGGER IF EXISTS signatures_updated_at ON signatures;
CREATE TRIGGER signatures_updated_at
BEFORE UPDATE ON signatures
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
-- RLS
ALTER TABLE signatures ENABLE ROW LEVEL SECURITY;
-- Tout utilisateur authentifié peut créer des signatures
CREATE POLICY "Users peuvent creer signatures" ON signatures
FOR INSERT WITH CHECK (auth.uid() = created_by);
-- Users peuvent voir leurs propres signatures
CREATE POLICY "Users peuvent voir leurs signatures" ON signatures
FOR SELECT USING (auth.uid() = created_by);
-- Users peuvent modifier leurs propres signatures
CREATE POLICY "Users peuvent modifier leurs signatures" ON signatures
FOR UPDATE USING (auth.uid() = created_by);
-- Users peuvent supprimer leurs propres signatures
CREATE POLICY "Users peuvent supprimer leurs signatures" ON signatures
FOR DELETE USING (auth.uid() = created_by);

34
tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}