293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
'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>
|
||
);
|
||
}
|