coupon code system eingeführt. coupons werden vom super admin gemanaged. coupons werden mit paddle synchronisiert und dort validiert. plus: einige mobil-optimierungen im tenant admin pwa.
This commit is contained in:
@@ -3,11 +3,13 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlignLeft,
|
||||
BadgeCheck,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Heading,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
Megaphone,
|
||||
Minus,
|
||||
Plus,
|
||||
Printer,
|
||||
QrCode,
|
||||
@@ -27,6 +29,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { EventQrInvite, EventQrInviteLayout } from '../../api';
|
||||
@@ -241,6 +244,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
const [zoomScale, setZoomScale] = React.useState(1);
|
||||
const [fitScale, setFitScale] = React.useState(1);
|
||||
const [previewMode, setPreviewMode] = React.useState<'fit' | 'full'>('fit');
|
||||
const [isCompact, setIsCompact] = React.useState(false);
|
||||
const fitScaleRef = React.useRef(1);
|
||||
const manualZoomRef = React.useRef(false);
|
||||
const actionsSentinelRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@@ -252,6 +256,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
const designerViewportRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const canvasContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const draftSignatureRef = React.useRef<string | null>(null);
|
||||
const initialElementsRef = React.useRef<LayoutElement[]>([]);
|
||||
const activeCustomization = React.useMemo(
|
||||
() => draftCustomization ?? initialCustomization ?? null,
|
||||
[draftCustomization, initialCustomization],
|
||||
@@ -264,6 +269,34 @@ export function InviteLayoutCustomizerPanel({
|
||||
const appliedLayoutRef = React.useRef<string | null>(null);
|
||||
const appliedInviteRef = React.useRef<number | string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
setIsCompact(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = window.matchMedia('(max-width: 1023px)');
|
||||
const update = (event?: MediaQueryListEvent) => {
|
||||
if (typeof event?.matches === 'boolean') {
|
||||
setIsCompact(event.matches);
|
||||
return;
|
||||
}
|
||||
setIsCompact(query.matches);
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
if (typeof query.addEventListener === 'function') {
|
||||
const listener = (event: MediaQueryListEvent) => update(event);
|
||||
query.addEventListener('change', listener);
|
||||
return () => query.removeEventListener('change', listener);
|
||||
}
|
||||
|
||||
const legacyListener = (event: MediaQueryListEvent) => update(event);
|
||||
query.addListener(legacyListener);
|
||||
return () => query.removeListener(legacyListener);
|
||||
}, []);
|
||||
|
||||
const clampZoom = React.useCallback(
|
||||
(value: number) => clamp(Number.isFinite(value) ? value : 1, ZOOM_MIN, ZOOM_MAX),
|
||||
[],
|
||||
@@ -410,7 +443,8 @@ export function InviteLayoutCustomizerPanel({
|
||||
const commitElements = React.useCallback(
|
||||
(producer: (current: LayoutElement[]) => LayoutElement[], options?: { silent?: boolean }) => {
|
||||
setElements((prev) => {
|
||||
const base = cloneElements(prev);
|
||||
const source = prev.length ? prev : initialElementsRef.current;
|
||||
const base = cloneElements(source.length ? source : []);
|
||||
const produced = producer(base);
|
||||
const normalized = normalizeElements(produced);
|
||||
if (elementsAreEqual(prev, normalized)) {
|
||||
@@ -514,6 +548,14 @@ export function InviteLayoutCustomizerPanel({
|
||||
}, [clampZoom, zoomScale, fitScale, previewMode]);
|
||||
const zoomPercent = Math.round(effectiveScale * 100);
|
||||
|
||||
const handleZoomStep = React.useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
manualZoomRef.current = true;
|
||||
setZoomScale((current) => clampZoom(current + direction * ZOOM_STEP));
|
||||
},
|
||||
[clampZoom]
|
||||
);
|
||||
|
||||
const updateElement = React.useCallback(
|
||||
(id: string, updater: Partial<LayoutElement> | ((element: LayoutElement) => Partial<LayoutElement>), options?: { silent?: boolean }) => {
|
||||
commitElements(
|
||||
@@ -646,6 +688,7 @@ export function InviteLayoutCustomizerPanel({
|
||||
setInstructions([]);
|
||||
commitElements(() => [], { silent: true });
|
||||
resetHistory([]);
|
||||
initialElementsRef.current = [];
|
||||
appliedSignatureRef.current = null;
|
||||
appliedLayoutRef.current = layoutId;
|
||||
appliedInviteRef.current = inviteKey;
|
||||
@@ -723,12 +766,15 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
if (isCustomizedAdvanced) {
|
||||
const initialElements = normalizeElements(payloadToElements(newForm.elements));
|
||||
initialElementsRef.current = initialElements;
|
||||
commitElements(() => initialElements, { silent: true });
|
||||
resetHistory(initialElements);
|
||||
} else {
|
||||
const defaults = buildDefaultElements(activeLayout, newForm, eventName, fallbackQrSize);
|
||||
commitElements(() => defaults, { silent: true });
|
||||
resetHistory(defaults);
|
||||
const normalizedDefaults = normalizeElements(defaults);
|
||||
initialElementsRef.current = normalizedDefaults;
|
||||
commitElements(() => normalizedDefaults, { silent: true });
|
||||
resetHistory(normalizedDefaults);
|
||||
}
|
||||
|
||||
appliedSignatureRef.current = incomingSignature ?? null;
|
||||
@@ -1515,6 +1561,38 @@ export function InviteLayoutCustomizerPanel({
|
||||
|
||||
const highlightedElementId = activeElementId ?? inspectorElementId;
|
||||
|
||||
const renderResponsiveSection = React.useCallback(
|
||||
(id: string, title: string, description: string, content: React.ReactNode) => {
|
||||
const body = <div className="space-y-4">{content}</div>;
|
||||
|
||||
if (!isCompact) {
|
||||
return (
|
||||
<section key={id} className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||
</header>
|
||||
{body}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible key={id} defaultOpen className="rounded-2xl border border-border bg-[var(--tenant-surface)] p-3 shadow-sm transition-colors">
|
||||
<CollapsibleTrigger type="button" className="flex w-full items-center justify-between gap-3 text-left">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
||||
{description ? <p className="text-xs text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-4">{body}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
},
|
||||
[isCompact]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="hidden flex-wrap items-center justify-end gap-2 lg:flex">
|
||||
@@ -1525,63 +1603,58 @@ export function InviteLayoutCustomizerPanel({
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
|
||||
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">{t('invites.customizer.sections.layouts', 'Layouts')}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.')}</p>
|
||||
</header>
|
||||
<div className="flex flex-col gap-6 xl:grid xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
|
||||
<form ref={formRef} onSubmit={handleSubmit} className={cn('order-2 space-y-6', 'xl:order-1')}>
|
||||
{renderResponsiveSection(
|
||||
'layouts',
|
||||
t('invites.customizer.sections.layouts', 'Layouts'),
|
||||
t('invites.customizer.sections.layoutsHint', 'Wähle eine Vorlage als Basis aus. Du kannst jederzeit wechseln.'),
|
||||
<>
|
||||
<Select
|
||||
value={activeLayout?.id ?? undefined}
|
||||
onValueChange={(value) => {
|
||||
const layout = availableLayouts.find((item) => item.id === value);
|
||||
if (layout) {
|
||||
handleLayoutSelect(layout);
|
||||
}
|
||||
}}
|
||||
disabled={!availableLayouts.length}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t('invites.customizer.layoutFallback', 'Layout')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
{availableLayouts.map((layout) => (
|
||||
<SelectItem key={layout.id} value={layout.id}>
|
||||
<div className="flex w-full flex-col gap-1 text-left">
|
||||
<span className="text-sm font-medium text-foreground">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</span>
|
||||
{layout.subtitle ? <span className="text-xs text-muted-foreground">{layout.subtitle}</span> : null}
|
||||
{layout.formats?.length ? (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-amber-700">
|
||||
{layout.formats.map((format) => String(format).toUpperCase()).join(' · ')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={activeLayout?.id ?? undefined}
|
||||
onValueChange={(value) => {
|
||||
const layout = availableLayouts.find((item) => item.id === value);
|
||||
if (layout) {
|
||||
handleLayoutSelect(layout);
|
||||
}
|
||||
}}
|
||||
disabled={!availableLayouts.length}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={t('invites.customizer.layoutFallback', 'Layout')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
{availableLayouts.map((layout) => (
|
||||
<SelectItem key={layout.id} value={layout.id}>
|
||||
<div className="flex w-full flex-col gap-1 text-left">
|
||||
<span className="text-sm font-medium text-foreground">{layout.name || t('invites.customizer.layoutFallback', 'Layout')}</span>
|
||||
{layout.subtitle ? <span className="text-xs text-muted-foreground">{layout.subtitle}</span> : null}
|
||||
{layout.formats?.length ? (
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-amber-700">
|
||||
{layout.formats.map((format) => String(format).toUpperCase()).join(' · ')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{activeLayout ? (
|
||||
<div className="rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-4 text-sm text-[var(--tenant-foreground-soft)] transition-colors">
|
||||
<p className="font-medium text-foreground">{activeLayout.name}</p>
|
||||
{activeLayout.subtitle ? <p className="mt-1 text-xs text-muted-foreground">{activeLayout.subtitle}</p> : null}
|
||||
{activeLayout.description ? <p className="mt-2 leading-relaxed text-muted-foreground">{activeLayout.description}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
|
||||
<header className="space-y-1">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('invites.customizer.elements.title', 'Elemente & Positionierung')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('invites.customizer.elements.hint', 'Wähle ein Element aus, um es zu verschieben, anzupassen oder zu entfernen.')}
|
||||
</p>
|
||||
</header>
|
||||
{activeLayout ? (
|
||||
<div className="rounded-xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-muted)] p-4 text-sm text-[var(--tenant-foreground-soft)] transition-colors">
|
||||
<p className="font-medium text-foreground">{activeLayout.name}</p>
|
||||
{activeLayout.subtitle ? <p className="mt-1 text-xs text-muted-foreground">{activeLayout.subtitle}</p> : null}
|
||||
{activeLayout.description ? <p className="mt-2 leading-relaxed text-muted-foreground">{activeLayout.description}</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
{renderResponsiveSection(
|
||||
'elements',
|
||||
t('invites.customizer.elements.title', 'Elemente & Positionierung'),
|
||||
t('invites.customizer.elements.hint', 'Wähle ein Element aus, um es zu verschieben, anzupassen oder zu entfernen.'),
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{sortedElements.map((element) => {
|
||||
const Icon = elementIconFor(element);
|
||||
@@ -1652,16 +1725,20 @@ export function InviteLayoutCustomizerPanel({
|
||||
{t('invites.customizer.elements.listHint', 'Wähle ein Element aus, um Einstellungen direkt unter dem Eintrag anzuzeigen.')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<section className="space-y-4 rounded-2xl border border-border bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
|
||||
{renderResponsiveSection(
|
||||
'content',
|
||||
t('invites.customizer.sections.content', 'Texte & Branding'),
|
||||
t('invites.customizer.sections.contentHint', 'Passe Texte, Anleitungsschritte und Farben deiner Einladung an.'),
|
||||
<Tabs defaultValue="text" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsList className="grid w-full grid-cols-3 gap-1 text-xs sm:text-sm">
|
||||
<TabsTrigger value="text">{t('invites.customizer.sections.text', 'Texte')}</TabsTrigger>
|
||||
<TabsTrigger value="instructions">{t('invites.customizer.sections.instructions', 'Schritt-für-Schritt')}</TabsTrigger>
|
||||
<TabsTrigger value="branding">{t('invites.customizer.sections.branding', 'Farbgebung')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
<TabsContent value="text" className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -1825,34 +1902,60 @@ export function InviteLayoutCustomizerPanel({
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className={cn('mt-6 flex flex-col gap-2 sm:flex-row sm:justify-end lg:hidden', showFloatingActions ? 'hidden' : 'flex')}>
|
||||
{renderActionButtons('inline')}
|
||||
</div>
|
||||
<div ref={actionsSentinelRef} className="h-1 w-full" />
|
||||
</form>
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors">
|
||||
<div className={cn('order-1 flex flex-col gap-4 rounded-2xl border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface)] p-5 shadow-sm transition-colors', 'xl:order-2')}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t('invites.customizer.controls.zoom', 'Zoom')}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={ZOOM_MIN}
|
||||
max={ZOOM_MAX}
|
||||
step={ZOOM_STEP}
|
||||
value={effectiveScale}
|
||||
onChange={(event) => {
|
||||
manualZoomRef.current = true;
|
||||
setZoomScale(clampZoom(Number(event.target.value)));
|
||||
}}
|
||||
className="h-1 w-36 overflow-hidden rounded-full"
|
||||
disabled={previewMode === 'full'}
|
||||
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
|
||||
/>
|
||||
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
|
||||
{!isCompact ? (
|
||||
<>
|
||||
<input
|
||||
type="range"
|
||||
min={ZOOM_MIN}
|
||||
max={ZOOM_MAX}
|
||||
step={ZOOM_STEP}
|
||||
value={effectiveScale}
|
||||
onChange={(event) => {
|
||||
manualZoomRef.current = true;
|
||||
setZoomScale(clampZoom(Number(event.target.value)));
|
||||
}}
|
||||
className="h-1 w-36 overflow-hidden rounded-full"
|
||||
disabled={previewMode === 'full'}
|
||||
aria-label={t('invites.customizer.controls.zoom', 'Zoom')}
|
||||
/>
|
||||
<span className="tabular-nums text-sm text-muted-foreground">{zoomPercent}%</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleZoomStep(-1)}
|
||||
aria-label={t('invites.customizer.controls.zoomOut', 'Verkleinern')}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="w-12 text-center text-xs font-medium tabular-nums text-muted-foreground">{zoomPercent}%</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleZoomStep(1)}
|
||||
aria-label={t('invites.customizer.controls.zoomIn', 'Vergrößern')}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ToggleGroup type="single" value={previewMode} onValueChange={(val) => setPreviewMode(val as 'fit' | 'full')} className="flex">
|
||||
<ToggleGroupItem value="fit" className="px-2 text-xs">
|
||||
Fit
|
||||
@@ -1861,20 +1964,37 @@ export function InviteLayoutCustomizerPanel({
|
||||
100%
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
manualZoomRef.current = false;
|
||||
const fitValue = clampZoom(fitScaleRef.current);
|
||||
setZoomScale(fitValue);
|
||||
setPreviewMode('fit');
|
||||
}}
|
||||
disabled={previewMode === 'full' || Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
|
||||
>
|
||||
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
|
||||
</Button>
|
||||
{!isCompact ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
manualZoomRef.current = false;
|
||||
const fitValue = clampZoom(fitScaleRef.current);
|
||||
setZoomScale(fitValue);
|
||||
setPreviewMode('fit');
|
||||
}}
|
||||
disabled={previewMode === 'full' || Math.abs(effectiveScale - clampZoom(fitScaleRef.current)) < 0.001}
|
||||
>
|
||||
{t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
manualZoomRef.current = false;
|
||||
const fitValue = clampZoom(fitScaleRef.current);
|
||||
setZoomScale(fitValue);
|
||||
setPreviewMode('fit');
|
||||
}}
|
||||
aria-label={t('invites.customizer.actions.zoomFit', 'Auf Bildschirm')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
|
||||
@@ -312,11 +312,29 @@ export function DesignerCanvas({
|
||||
canvas.on('selection:cleared', handleSelectionCleared);
|
||||
canvas.on('object:modified', handleObjectModified);
|
||||
|
||||
const handleEditingExited = (event: { target?: FabricObjectWithId & { text?: string } }) => {
|
||||
if (readOnly) {
|
||||
return;
|
||||
}
|
||||
const target = event?.target;
|
||||
if (!target || typeof target.elementId !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedText = typeof (target as fabric.Textbox).text === 'string' ? (target as fabric.Textbox).text : target.text ?? '';
|
||||
handleObjectModified({ target });
|
||||
onChange(target.elementId, { content: updatedText });
|
||||
canvas.requestRenderAll();
|
||||
};
|
||||
|
||||
canvas.on('editing:exited', handleEditingExited);
|
||||
|
||||
return () => {
|
||||
canvas.off('selection:created', handleSelection);
|
||||
canvas.off('selection:updated', handleSelection);
|
||||
canvas.off('selection:cleared', handleSelectionCleared);
|
||||
canvas.off('object:modified', handleObjectModified);
|
||||
canvas.off('editing:exited', handleEditingExited);
|
||||
};
|
||||
}, [onChange, onSelect, readOnly]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user