Files
IdP/apps/docs/components/one-tap-button-builder.tsx
2026-07-01 17:00:55 +03:00

293 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
import { CodeBlock } from '@/components/code-block';
import { cn } from '@/lib/utils';
import {
buildButtonSnippet,
DEFAULT_BUILDER_OPTIONS,
ICON_OPTIONS,
resolveButtonStyle,
SIZE_OPTIONS,
THEME_OPTIONS,
VIEW_OPTIONS,
type ButtonBuilderOptions
} from '@/lib/one-tap-builder';
import { useOneTapUrls } from '@/lib/use-one-tap-urls';
type ColorKey = 'bg' | 'bgHover' | 'border' | 'borderHover' | 'text';
const COLOR_FIELDS: Array<{ key: ColorKey; label: string }> = [
{ key: 'bg', label: 'Цвет фона' },
{ key: 'bgHover', label: 'Фон при наведении' },
{ key: 'border', label: 'Цвет обводки' },
{ key: 'borderHover', label: 'Обводка при наведении' },
{ key: 'text', label: 'Цвет текста' }
];
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-zinc-500 dark:text-zinc-400">{label}</span>
{children}
</label>
);
}
const inputClass =
'h-9 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition focus:border-zinc-400 focus:ring-2 focus:ring-zinc-200 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:ring-zinc-700';
export function OneTapButtonBuilder() {
const { urls, loading, error, frontendBase, projectName } = useOneTapUrls();
const [options, setOptions] = useState<ButtonBuilderOptions>(DEFAULT_BUILDER_OPTIONS);
const [hovered, setHovered] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const snippet = useMemo(() => (urls ? buildButtonSnippet(options, urls) : ''), [options, urls]);
const style = useMemo(() => resolveButtonStyle(options), [options]);
useEffect(() => {
if (!urls?.projectName) return;
setOptions((prev) =>
prev.providerName === DEFAULT_BUILDER_OPTIONS.providerName ? { ...prev, providerName: urls.projectName } : prev
);
}, [urls?.projectName]);
if (loading) {
return <p className="text-sm text-zinc-500 dark:text-zinc-400">Загрузка актуальных URL из настроек IdP</p>;
}
if (error || !urls) {
return (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
{error ?? 'Не удалось загрузить настройки для конструктора кнопок.'}
</p>
);
}
function update<K extends keyof ButtonBuilderOptions>(key: K, value: ButtonBuilderOptions[K]) {
setOptions((prev) => ({ ...prev, [key]: value }));
}
function toggleColor(key: ColorKey, enabled: boolean) {
setOptions((prev) => ({
...prev,
[key]: enabled ? (prev[key] || style.palette[key] || '#ffffff') : ''
}));
}
const buttonStyle: CSSProperties = {
height: style.preset.height,
fontSize: style.preset.font,
gap: style.preset.gap,
padding: style.iconOnly ? 0 : `0 ${style.preset.padX}px`,
width: style.iconOnly ? style.preset.height : 'auto',
borderRadius: style.radius,
background: hovered ? style.bgHover : style.bg,
color: style.text,
border: `1px solid ${hovered ? style.borderHover : style.border}`,
boxShadow: '0 8px 24px rgba(31,36,48,.12)',
transition: 'all .18s ease'
};
const badgeStyle: CSSProperties = {
minWidth: style.preset.badge,
height: style.preset.badge,
padding: `0 ${Math.round(style.preset.badge / 4)}px`,
fontSize: Math.round(style.preset.font * 0.85),
background: style.palette.badgeBg,
color: style.palette.badgeColor
};
function tryLogin() {
if (typeof window === 'undefined') return;
const params = new URLSearchParams({
client_id: options.clientId || 'YOUR_CLIENT_ID',
redirect_uri: options.redirectUri,
response_type: 'code',
scope: 'openid profile email',
display: 'popup',
popup_origin: window.location.origin,
state: 'preview_' + Math.random().toString(36).slice(2)
});
const url = `${frontendBase}/auth/oauth/authorize?${params.toString()}`;
const popup = window.open(url, 'mvkid_preview', 'popup,width=480,height=640');
if (!popup) {
setStatus('Браузер заблокировал popup — разрешите всплывающие окна для теста.');
return;
}
setStatus('Открыт popup авторизации. После входа токен придёт через postMessage.');
function onMessage(event: MessageEvent) {
if (event.data?.type !== 'lendry-sso-onetap') return;
window.removeEventListener('message', onMessage);
setStatus(`Получен ответ: метод ${event.data.method ?? 'popup'}.`);
}
window.addEventListener('message', onMessage);
}
return (
<div className="my-4 grid gap-4 lg:grid-cols-[1.1fr_1fr]">
{/* Левая колонка: превью + код */}
<div className="space-y-3">
<div className="flex min-h-[140px] items-center justify-center rounded-xl border border-zinc-200 bg-[#e9edf3] p-6 dark:border-zinc-800 dark:bg-[#15171c]">
<button
type="button"
className="inline-flex cursor-pointer items-center justify-center font-semibold leading-none"
style={buttonStyle}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={tryLogin}
aria-label={`Войти через ${options.providerName || projectName}`}
>
{style.showBadge ? (
<span className="inline-flex items-center justify-center rounded-full font-bold" style={badgeStyle}>
ID
</span>
) : null}
{!style.iconOnly ? <span>Войти через {options.providerName || projectName}</span> : null}
</button>
</div>
{status ? (
<p className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-300">
{status}
</p>
) : null}
<CodeBlock code={snippet} language="html" title="Код для вставки" />
</div>
{/* Правая колонка: параметры */}
<div className="space-y-3 rounded-xl border border-zinc-200 p-4 dark:border-zinc-800">
<Field label="Client ID (из админки: RBAC → OAuth приложения)">
<input
className={inputClass}
value={options.clientId}
onChange={(event) => update('clientId', event.target.value)}
placeholder="YOUR_CLIENT_ID"
/>
</Field>
<Field label="Redirect URI">
<input
className={inputClass}
value={options.redirectUri}
onChange={(event) => update('redirectUri', event.target.value)}
placeholder="https://app.example.com/auth/callback"
/>
</Field>
<Field label="Название провайдера (текст на кнопке)">
<input
className={inputClass}
value={options.providerName}
onChange={(event) => update('providerName', event.target.value)}
placeholder={projectName}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Размер">
<select
className={inputClass}
value={options.size}
onChange={(event) => update('size', event.target.value as ButtonBuilderOptions['size'])}
>
{SIZE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</Field>
<Field label="Тема">
<select
className={inputClass}
value={options.theme}
onChange={(event) => update('theme', event.target.value as ButtonBuilderOptions['theme'])}
>
{THEME_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</Field>
<Field label="Вид">
<select
className={inputClass}
value={options.view}
onChange={(event) => update('view', event.target.value as ButtonBuilderOptions['view'])}
>
{VIEW_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</Field>
<Field label="Тип иконки">
<select
className={inputClass}
value={options.icon}
onChange={(event) => update('icon', event.target.value as ButtonBuilderOptions['icon'])}
>
{ICON_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</Field>
</div>
<Field label={`Радиус скругления — ${options.radius}px`}>
<input
type="range"
min={0}
max={32}
value={options.radius}
onChange={(event) => update('radius', Number(event.target.value))}
className="w-full accent-zinc-900 dark:accent-zinc-100"
/>
</Field>
<div className="space-y-2 border-t border-zinc-200 pt-3 dark:border-zinc-800">
<p className="text-xs font-medium text-zinc-500 dark:text-zinc-400">
CSS-цвета (необязательно иначе берётся цвет темы)
</p>
{COLOR_FIELDS.map((field) => {
const enabled = Boolean(options[field.key]);
const fallback = style.palette[field.key] || '#ffffff';
const value = options[field.key] || fallback;
return (
<div key={field.key} className="flex items-center gap-3">
<input
type="checkbox"
checked={enabled}
onChange={(event) => toggleColor(field.key, event.target.checked)}
className="h-4 w-4 accent-zinc-900 dark:accent-zinc-100"
/>
<input
type="color"
value={value.startsWith('#') ? value : '#ffffff'}
disabled={!enabled}
onChange={(event) => update(field.key, event.target.value)}
className={cn('h-8 w-10 cursor-pointer rounded border border-zinc-300 bg-transparent dark:border-zinc-700', !enabled && 'opacity-40')}
/>
<span className="text-sm text-zinc-600 dark:text-zinc-300">{field.label}</span>
{enabled ? (
<code className="ml-auto rounded bg-zinc-100 px-1.5 py-0.5 text-xs dark:bg-zinc-800">{options[field.key]}</code>
) : null}
</div>
);
})}
</div>
</div>
</div>
);
}