mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 06:38:05 +02:00
Renouveler un jeton demandait de deviner que « Reconnecter » servait à ça, et les outils Meta / LinkedIn étaient enfouis sous « Comment obtenir ces identifiants ? ». Chaque réseau connecté porte maintenant un bloc dédié : - Un bouton « Renouveler le jeton <réseau> », qui relance l'échange OAuth complet (jeton court → longue durée → jeton de page). - Les deux liens utiles à ce moment-là, chacun avec ce qu'il sert à faire : débogueur de jeton et outil de jetons d'accès pour Facebook, jetons de l'application et documentation de durée de vie pour LinkedIn. - Le bloc passe en ambre quand le jeton est mort, expiré ou proche de l'être. « Reconnecter » disparaît : il faisait exactement la même chose que le nouveau bouton, à dix pixels de distance. Une fois connecté, il ne reste que « Déconnecter » ; « Connecter » n'apparaît plus que pour la première liaison. Les bannières d'alerte nomment désormais le bouton qui existe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
519 lines
22 KiB
TypeScript
519 lines
22 KiB
TypeScript
import { headers } from "next/headers";
|
||
import { originFromHeaders } from "@/lib/site-url";
|
||
import { redirect } from "next/navigation";
|
||
import type { CSSProperties } from "react";
|
||
import { getSession } from "@/lib/session";
|
||
import { can } from "@/lib/rbac";
|
||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||
import {
|
||
checkTokenHealth,
|
||
expiryStatus,
|
||
getSocialAccounts,
|
||
listStoredTargets,
|
||
redirectUri,
|
||
siteUrl,
|
||
SOCIAL_LABELS,
|
||
SOCIAL_NETWORKS,
|
||
type SocialTarget,
|
||
type TokenHealth,
|
||
} from "@/lib/social-accounts";
|
||
import { disconnectSocial, saveSitePublicUrl, saveSocialApp, selectSocialTarget } from "../actions";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
const HELP: Record<
|
||
(typeof SOCIAL_NETWORKS)[number],
|
||
{
|
||
portal: string;
|
||
steps: string[];
|
||
caution?: string;
|
||
tools?: { label: string; href: string }[];
|
||
/** Les deux liens utiles au moment de renouveler un jeton. */
|
||
renew: { note: string; links: { label: string; hint: string; href: string }[] };
|
||
}
|
||
> = {
|
||
facebook: {
|
||
portal: "https://developers.facebook.com/apps",
|
||
steps: [
|
||
"Créez une application de type « Business » et relevez l'identifiant et la clé secrète (Paramètres › Général).",
|
||
"Ajoutez le produit « Connexion Facebook » puis collez l'URL de redirection ci-dessous dans « URI de redirection OAuth valides ».",
|
||
"Laissez l'application en mode développement et ajoutez le compte de l'association comme administrateur : publier sur votre propre page ne demande alors aucune revue Meta.",
|
||
],
|
||
renew: {
|
||
note:
|
||
"Le bouton refait tout l'échange : jeton court, puis jeton de longue durée, puis jeton de page. " +
|
||
"Vous n'avez aucun jeton à copier à la main — les deux liens ci-dessous ne servent qu'à vérifier.",
|
||
links: [
|
||
{
|
||
label: "Débogueur de jeton",
|
||
hint: "coller un jeton pour voir sa validité et sa date d'expiration",
|
||
href: "https://developers.facebook.com/tools/debug/accesstoken/",
|
||
},
|
||
{
|
||
label: "Outil de jetons d'accès",
|
||
hint: "lister les jetons de vos pages et en générer un de longue durée",
|
||
href: "https://developers.facebook.com/tools/accesstoken/",
|
||
},
|
||
],
|
||
},
|
||
tools: [
|
||
{ label: "Débogueur de jeton (vérifier un jeton et sa date d'expiration)", href: "https://developers.facebook.com/tools/debug/accesstoken/" },
|
||
{ label: "Outil de jetons d'accès (voir les jetons de vos pages)", href: "https://developers.facebook.com/tools/accesstoken/" },
|
||
{ label: "Explorateur d'API Graph (tester un appel à la main)", href: "https://developers.facebook.com/tools/explorer/" },
|
||
{ label: "Documentation : jetons de longue durée", href: "https://developers.facebook.com/docs/facebook-login/guides/access-tokens/get-long-lived" },
|
||
],
|
||
},
|
||
linkedin: {
|
||
portal: "https://www.linkedin.com/developers/apps",
|
||
steps: [
|
||
"Créez une application rattachée à la page LinkedIn de l'association et relevez le Client ID et le Client Secret (onglet Auth).",
|
||
"Collez l'URL de redirection ci-dessous dans « Authorized redirect URLs ».",
|
||
"Onglet Produits : demandez « Community Management API », indispensable pour publier au nom de la page.",
|
||
],
|
||
caution:
|
||
"LinkedIn soumet cette demande à une revue (page vérifiée, nom légal, adresse, politique de confidentialité) et ses jetons expirent au bout de 60 jours : il faudra recliquer sur « Renouveler le jeton » environ tous les deux mois.",
|
||
renew: {
|
||
note:
|
||
"Les jetons LinkedIn expirent au bout de 60 jours : le bouton relance l'autorisation et " +
|
||
"repart pour deux mois. Les deux liens ci-dessous permettent de contrôler l'échéance.",
|
||
links: [
|
||
{
|
||
label: "Jetons de votre application",
|
||
hint: "voir la durée de validité restante des jetons émis",
|
||
href: "https://www.linkedin.com/developers/tools/oauth",
|
||
},
|
||
{
|
||
label: "Documentation : durée de vie des jetons",
|
||
hint: "règles de rafraîchissement et conditions d'accès",
|
||
href: "https://learn.microsoft.com/linkedin/shared/authentication/programmatic-refresh-tokens",
|
||
},
|
||
],
|
||
},
|
||
tools: [
|
||
{ label: "Jetons de votre application (durée de validité)", href: "https://www.linkedin.com/developers/tools/oauth" },
|
||
{ label: "Documentation : durée de vie et rafraîchissement des jetons", href: "https://learn.microsoft.com/linkedin/shared/authentication/programmatic-refresh-tokens" },
|
||
],
|
||
},
|
||
};
|
||
|
||
function fmtDate(d: Date) {
|
||
return new Date(d).toLocaleDateString("fr-FR", { day: "numeric", month: "long", year: "numeric" });
|
||
}
|
||
|
||
export default async function ReseauxPage({
|
||
searchParams,
|
||
}: {
|
||
searchParams: Promise<{ error?: string; connected?: string; choose?: string }>;
|
||
}) {
|
||
const session = await getSession();
|
||
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
|
||
|
||
const { error, connected, choose } = await searchParams;
|
||
const accounts = await getSocialAccounts();
|
||
const byNetwork = new Map(accounts.map((a) => [a.network, a]));
|
||
const base = await siteUrl();
|
||
|
||
// Adresse par laquelle l'administrateur consulte cette page : proposée par
|
||
// défaut pour lui éviter de la recopier à la main.
|
||
const detected = originFromHeaders(await headers());
|
||
|
||
// Calculées ici : le rendu des cartes n'est pas asynchrone.
|
||
const redirectUris = new Map<string, string>();
|
||
for (const network of SOCIAL_NETWORKS) {
|
||
redirectUris.set(network, await redirectUri(network));
|
||
}
|
||
|
||
// État réel du jeton, demandé à la plateforme à chaque affichage : c'est le
|
||
// seul moyen de savoir qu'un jeton « sans expiration » a été révoqué.
|
||
const health = new Map<string, TokenHealth | null>();
|
||
for (const network of SOCIAL_NETWORKS) {
|
||
const account = byNetwork.get(network);
|
||
if (account?.accessToken && account.targetId) {
|
||
health.set(network, await checkTokenHealth(network));
|
||
}
|
||
}
|
||
|
||
// Cibles à proposer quand le compte administre plusieurs pages.
|
||
const targets = new Map<string, SocialTarget[]>();
|
||
let targetError: string | null = null;
|
||
for (const network of SOCIAL_NETWORKS) {
|
||
const account = byNetwork.get(network);
|
||
if (account?.accessToken && !account.targetId) {
|
||
try {
|
||
targets.set(network, await listStoredTargets(network));
|
||
} catch (caught) {
|
||
targetError = caught instanceof Error ? caught.message : "Pages illisibles.";
|
||
}
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div style={{ display: "grid", gap: 22, maxWidth: 1080 }}>
|
||
{error && <Banner tone="error">{error}</Banner>}
|
||
{targetError && <Banner tone="error">{targetError}</Banner>}
|
||
{connected && (
|
||
<Banner tone="ok">
|
||
Compte {SOCIAL_LABELS[connected as "facebook"] ?? connected} connecté.
|
||
</Banner>
|
||
)}
|
||
{choose && (
|
||
<Banner tone="warn">
|
||
Connexion réussie : choisissez la page à utiliser pour les publications.
|
||
</Banner>
|
||
)}
|
||
|
||
<section style={panel}>
|
||
<h2 className="font-display" style={{ ...title, margin: "0 0 4px" }}>
|
||
URL publique du site
|
||
</h2>
|
||
<p style={{ margin: "0 0 14px", fontSize: 13.5, color: "#6c6150", lineHeight: 1.6 }}>
|
||
Adresse à laquelle vos visiteurs accèdent au site. Elle sert à construire
|
||
l'adresse de retour des connexions Facebook et LinkedIn, et le lien inséré dans
|
||
les publications.
|
||
</p>
|
||
<form action={saveSitePublicUrl} style={{ display: "flex", gap: 10, flexWrap: "wrap", alignItems: "flex-end" }}>
|
||
<div style={{ flex: "1 1 320px", minWidth: 0 }}>
|
||
<label className="field-label">Adresse</label>
|
||
<input
|
||
name="sitePublicUrl"
|
||
className="field"
|
||
defaultValue={base || detected}
|
||
placeholder="https://pleinr.example.fr"
|
||
/>
|
||
</div>
|
||
<button type="submit" style={submitButton}>Enregistrer</button>
|
||
</form>
|
||
{!base && detected && (
|
||
<div style={{ marginTop: 10, fontSize: 12.5, color: "#9a6638" }}>
|
||
Aucune adresse enregistrée : celle de votre navigation, <strong>{detected}</strong>, est
|
||
utilisée par défaut. Facebook et LinkedIn n'acceptent qu'une adresse de retour
|
||
en <code>https</code> sur un nom de domaine : vérifiez-la puis enregistrez.
|
||
</div>
|
||
)}
|
||
{!base && !detected && (
|
||
<div style={{ marginTop: 10, fontSize: 12.5, color: "#a8503c" }}>
|
||
Renseignez cette adresse avant de connecter un réseau social.
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{SOCIAL_NETWORKS.map((network) => {
|
||
const account = byNetwork.get(network);
|
||
const help = HELP[network];
|
||
const status = expiryStatus(account?.expiresAt);
|
||
const isConnected = !!account?.accessToken && !!account.targetId;
|
||
const candidates = targets.get(network) ?? [];
|
||
const tokenHealth = health.get(network) ?? null;
|
||
const broken = tokenHealth?.ok === false;
|
||
// Le bloc de renouvellement passe en ambre quand il y a urgence :
|
||
// jeton mort, expiré, ou sur le point de l'être.
|
||
const needsRenewal = broken || status === "expired" || status === "soon";
|
||
|
||
return (
|
||
<section key={network} style={panel}>
|
||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 6 }}>
|
||
<span
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 12,
|
||
background: SOCIAL_BRAND[network],
|
||
color: "#fff",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<SocialIcon network={network} size={20} />
|
||
</span>
|
||
<h2 className="font-display" style={{ ...title, margin: 0, flex: 1 }}>
|
||
{SOCIAL_LABELS[network]}
|
||
</h2>
|
||
<StatusChip connected={isConnected} status={broken ? "expired" : status} />
|
||
</div>
|
||
|
||
{isConnected && (
|
||
<div style={{ fontSize: 13.5, color: "#6c6150", marginBottom: 14, lineHeight: 1.6 }}>
|
||
Publie sur <strong style={{ color: "#26201a" }}>{account?.targetName}</strong>.
|
||
{status === "never" && " Ce jeton n'expire pas."}
|
||
{account?.expiresAt && status !== "never" && ` Jeton valable jusqu'au ${fmtDate(account.expiresAt)}.`}
|
||
{account?.connectedAt && ` Connecté le ${fmtDate(account.connectedAt)}.`}
|
||
<br />
|
||
{tokenHealth?.ok && (
|
||
<span style={{ color: "#1f8a5b", fontWeight: 600 }}>
|
||
✓ Jeton vérifié à l'instant auprès de {SOCIAL_LABELS[network]} : il fonctionne.{" "}
|
||
</span>
|
||
)}
|
||
Pour changer de page ou renouveler l'accès, utilisez le bouton ci-dessous.
|
||
</div>
|
||
)}
|
||
|
||
{broken && (
|
||
<Banner tone="error">
|
||
Le jeton {SOCIAL_LABELS[network]} ne fonctionne plus : les publications échoueront
|
||
tant que vous n'aurez pas cliqué sur « Renouveler le jeton ».
|
||
<div style={{ marginTop: 6, fontSize: 12, opacity: 0.85 }}>
|
||
Réponse de la plateforme : {tokenHealth.reason.slice(0, 220)}
|
||
</div>
|
||
</Banner>
|
||
)}
|
||
|
||
{(status === "soon" || status === "expired") && isConnected && (
|
||
<Banner tone={status === "expired" ? "error" : "warn"}>
|
||
{status === "expired"
|
||
? `Le jeton ${SOCIAL_LABELS[network]} a expiré : les publications échoueront tant que vous ne l'aurez pas renouvelé.`
|
||
: `Le jeton ${SOCIAL_LABELS[network]} expire bientôt. Un clic sur « Renouveler le jeton » suffit.`}
|
||
</Banner>
|
||
)}
|
||
|
||
{candidates.length > 0 && (
|
||
<div style={{ background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 12, padding: 15, marginBottom: 16 }}>
|
||
<div className="field-label" style={{ marginBottom: 9 }}>
|
||
Page à utiliser pour les publications
|
||
</div>
|
||
<div style={{ display: "grid", gap: 8 }}>
|
||
{candidates.map((target) => (
|
||
<form key={target.id} action={selectSocialTarget} style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||
<input type="hidden" name="network" value={network} />
|
||
<input type="hidden" name="targetId" value={target.id} />
|
||
<span style={{ flex: 1, fontSize: 13.5, color: "#3c3322", fontWeight: 600 }}>
|
||
{target.name}
|
||
</span>
|
||
<button type="submit" style={{ border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 12.5, padding: "8px 14px", borderRadius: 9, cursor: "pointer" }}>
|
||
Choisir
|
||
</button>
|
||
</form>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<form action={saveSocialApp} style={{ marginBottom: 14 }}>
|
||
<input type="hidden" name="network" value={network} />
|
||
<div className="grid grid-2" style={{ gap: 16 }}>
|
||
<div>
|
||
<label className="field-label">
|
||
{network === "facebook" ? "Identifiant de l'application" : "Client ID"}
|
||
</label>
|
||
<input name="appId" defaultValue={account?.appId ?? ""} className="field" required />
|
||
</div>
|
||
<div>
|
||
<label className="field-label">
|
||
{network === "facebook" ? "Clé secrète" : "Client Secret"}
|
||
</label>
|
||
<input
|
||
name="appSecret"
|
||
type="password"
|
||
className="field"
|
||
autoComplete="new-password"
|
||
placeholder={account ? "Enregistré — laissez vide pour le conserver" : "Collez la clé secrète"}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<button type="submit" style={{ ...submitButton, marginTop: 14 }}>
|
||
Enregistrer les identifiants
|
||
</button>
|
||
</form>
|
||
|
||
{/* Renouvellement : l'action et ses deux liens de contrôle, visibles
|
||
sans avoir à déplier l'aide de configuration. */}
|
||
{isConnected && (
|
||
<div
|
||
style={{
|
||
background: needsRenewal ? "#fbeede" : "#faf7ef",
|
||
border: `1px solid ${needsRenewal ? "#ecd8b8" : "#f0e8d6"}`,
|
||
borderRadius: 12,
|
||
padding: 15,
|
||
marginBottom: 16,
|
||
}}
|
||
>
|
||
<div className="field-label" style={{ marginBottom: 4 }}>
|
||
Renouveler le jeton
|
||
</div>
|
||
<div style={{ fontSize: 12.5, color: "#8c8068", lineHeight: 1.6, marginBottom: 12 }}>
|
||
{help.renew.note}
|
||
</div>
|
||
<a
|
||
href={`/api/social/${network}/connect`}
|
||
className="font-display"
|
||
style={{
|
||
display: "inline-block",
|
||
textDecoration: "none",
|
||
background: SOCIAL_BRAND[network],
|
||
color: "#fff",
|
||
fontWeight: 700,
|
||
fontSize: 13.5,
|
||
padding: "11px 18px",
|
||
borderRadius: 10,
|
||
marginBottom: 12,
|
||
}}
|
||
>
|
||
Renouveler le jeton {SOCIAL_LABELS[network]}
|
||
</a>
|
||
<div style={{ display: "grid", gap: 8 }}>
|
||
{help.renew.links.map((link) => (
|
||
<a
|
||
key={link.href}
|
||
href={link.href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "baseline",
|
||
gap: 8,
|
||
textDecoration: "none",
|
||
fontSize: 13,
|
||
lineHeight: 1.5,
|
||
color: "#2C6FB3",
|
||
fontWeight: 700,
|
||
}}
|
||
>
|
||
{link.label} ↗
|
||
<span style={{ color: "#8c8068", fontWeight: 500, fontSize: 12.5 }}>
|
||
{link.hint}
|
||
</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", borderTop: "1px solid #f0e8d6", paddingTop: 14 }}>
|
||
{/* Une fois connecté, l'action vit dans le bloc de renouvellement
|
||
ci-dessus : deux boutons identiques côte à côte n'aidaient personne. */}
|
||
{!isConnected && (
|
||
<a
|
||
href={`/api/social/${network}/connect`}
|
||
className="font-display"
|
||
style={{
|
||
textDecoration: "none",
|
||
border: "none",
|
||
background: account?.appId ? SOCIAL_BRAND[network] : "#d8cdb4",
|
||
color: "#fff",
|
||
fontWeight: 700,
|
||
fontSize: 14,
|
||
padding: "12px 20px",
|
||
borderRadius: 10,
|
||
pointerEvents: account?.appId ? undefined : "none",
|
||
}}
|
||
>
|
||
Connecter
|
||
</a>
|
||
)}
|
||
{isConnected && (
|
||
<form action={disconnectSocial}>
|
||
<input type="hidden" name="network" value={network} />
|
||
<button type="submit" style={{ border: "1px solid #e0c3bb", background: "#fff", color: "#d8472b", fontWeight: 700, fontSize: 14, padding: "12px 18px", borderRadius: 10, cursor: "pointer" }}>
|
||
Déconnecter
|
||
</button>
|
||
</form>
|
||
)}
|
||
</div>
|
||
|
||
<details style={{ marginTop: 16 }}>
|
||
<summary style={{ cursor: "pointer", fontSize: 13.5, fontWeight: 700, color: "#9a6638" }}>
|
||
Comment obtenir ces identifiants ?
|
||
</summary>
|
||
<ol style={{ margin: "12px 0 0", paddingLeft: 20, fontSize: 13.5, color: "#6c6150", lineHeight: 1.7 }}>
|
||
{help.steps.map((step) => (
|
||
<li key={step}>{step}</li>
|
||
))}
|
||
</ol>
|
||
<div style={{ marginTop: 12, fontSize: 13, color: "#6c6150" }}>
|
||
Portail :{" "}
|
||
<a href={help.portal} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 700 }}>
|
||
{help.portal}
|
||
</a>
|
||
</div>
|
||
{help.tools && (
|
||
<div style={{ marginTop: 14 }}>
|
||
<div className="field-label">Outils pour inspecter un jeton</div>
|
||
<ul style={{ margin: "6px 0 0", paddingLeft: 20, fontSize: 13, lineHeight: 1.8 }}>
|
||
{help.tools.map((tool) => (
|
||
<li key={tool.href}>
|
||
<a href={tool.href} target="_blank" rel="noopener noreferrer" style={{ color: "#2C6FB3", fontWeight: 600 }}>
|
||
{tool.label}
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<div style={{ marginTop: 8, fontSize: 12.5, color: "#8c8068", lineHeight: 1.6 }}>
|
||
Ces outils servent à contrôler ou dépanner : le bouton
|
||
<strong> Connecter</strong> fait déjà l'échange complet — jeton court,
|
||
puis jeton de longue durée, puis jeton de page — vous n'avez aucun jeton à
|
||
copier à la main.
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ marginTop: 14 }}>
|
||
<div className="field-label">URL de redirection à déclarer</div>
|
||
<code style={{ display: "block", background: "#faf7ef", border: "1px solid #f0e8d6", borderRadius: 9, padding: "10px 12px", fontSize: 12.5, color: "#3c3322", overflowWrap: "anywhere" }}>
|
||
{base ? redirectUris.get(network) : "— renseignez d'abord l'URL publique du site ci-dessus —"}
|
||
</code>
|
||
</div>
|
||
{help.caution && (
|
||
<div style={{ marginTop: 12, background: "#fbeede", border: "1px solid #ecd8b8", color: "#9a6638", borderRadius: 10, padding: "11px 13px", fontSize: 13, lineHeight: 1.6 }}>
|
||
{help.caution}
|
||
</div>
|
||
)}
|
||
</details>
|
||
</section>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatusChip({ connected, status }: { connected: boolean; status: string }) {
|
||
const [label, bg, color] = !connected
|
||
? ["Non connecté", "#f1efe7", "#a99c82"]
|
||
: status === "expired"
|
||
? ["Jeton expiré", "#fbe9e6", "#d8472b"]
|
||
: status === "soon"
|
||
? ["Expire bientôt", "#fbeede", "#9a6638"]
|
||
: ["Connecté", "#e6f4ec", "#1f8a5b"];
|
||
return (
|
||
<span style={{ background: bg, color, borderRadius: 999, padding: "6px 13px", fontSize: 12, fontWeight: 800 }}>
|
||
{label}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function Banner({ tone, children }: { tone: "ok" | "warn" | "error"; children: React.ReactNode }) {
|
||
const palette = {
|
||
ok: { bg: "#e6f4ec", border: "#c7e6d5", color: "#1f8a5b" },
|
||
warn: { bg: "#fbeede", border: "#ecd8b8", color: "#9a6638" },
|
||
error: { bg: "#fbe9e6", border: "#f2d5cf", color: "#a8503c" },
|
||
}[tone];
|
||
return (
|
||
<div style={{ background: palette.bg, border: `1px solid ${palette.border}`, color: palette.color, borderRadius: 12, padding: "12px 16px", fontSize: 13.5, lineHeight: 1.6 }}>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const panel: CSSProperties = {
|
||
background: "#fff",
|
||
border: "1px solid #e6dcc6",
|
||
borderRadius: 16,
|
||
padding: 22,
|
||
};
|
||
|
||
const title: CSSProperties = {
|
||
fontSize: 20,
|
||
color: "#26201a",
|
||
};
|
||
|
||
const submitButton: CSSProperties = {
|
||
border: "none",
|
||
background: "#13324F",
|
||
color: "#fff",
|
||
fontWeight: 800,
|
||
fontSize: 14,
|
||
padding: "11px 20px",
|
||
borderRadius: 10,
|
||
cursor: "pointer",
|
||
};
|