Initial commit
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 été 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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`)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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!
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)$).*)',
|
||||
],
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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]
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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"]
|
||||
}
|
||||