@sublimee/auth-ui 0.1.0 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,139 +1,80 @@
1
1
  'use client';
2
2
 
3
- import { forwardRef, useMemo } from 'react';
4
- import { Button } from '@base-ui/react/button';
3
+ import { forwardRef } from 'react';
4
+
5
+ import { BaseButton } from './base-button';
6
+ import { DiscordIcon, GoogleIcon } from './icons';
5
7
  import type { OAuthButtonProps, OAuthProvider } from './types';
6
- import { GoogleIcon, DiscordIcon, SpinnerIcon } from './icons';
7
8
 
8
- /**
9
- * Maps OAuth providers to their display names
10
- */
11
9
  const PROVIDER_NAMES: Record<OAuthProvider, string> = {
12
10
  google: 'Google',
13
11
  discord: 'Discord',
14
12
  };
15
13
 
16
- /**
17
- * Returns the appropriate icon component for the provider
18
- */
19
- function getProviderIcon(provider: OAuthProvider) {
20
- switch (provider) {
21
- case 'google':
22
- return GoogleIcon;
23
- case 'discord':
24
- return DiscordIcon;
25
- default:
26
- return () => null;
27
- }
28
- }
29
-
30
- /**
31
- * Sensible defaults that every button should have.
32
- * These are UX fundamentals that don't affect visual design.
33
- */
34
- const SENSIBLE_DEFAULTS: readonly string[] = ['cursor-pointer'];
35
-
36
- /**
37
- * Merges sensible default classes with user-provided classes.
38
- * Prevents duplicate cursor classes if user already specified them.
39
- */
40
- function mergeButtonClasses(userClassName: string | undefined, isDisabled: boolean): string {
41
- const userClasses = userClassName?.trim() ?? '';
42
-
43
- // Filter out defaults that user already specified to avoid duplicates
44
- const neededDefaults = SENSIBLE_DEFAULTS.filter(
45
- defaultClass => !userClasses.includes(defaultClass)
46
- );
47
-
48
- // Add disabled cursor class if needed and not already present
49
- if (isDisabled && !userClasses.includes('cursor-not-allowed')) {
50
- neededDefaults.push('cursor-not-allowed');
51
- }
52
-
53
- if (neededDefaults.length === 0) {
54
- return userClasses;
55
- }
14
+ const PROVIDER_ICONS: Record<OAuthProvider, typeof GoogleIcon> = {
15
+ google: GoogleIcon,
16
+ discord: DiscordIcon,
17
+ };
56
18
 
57
- return `${neededDefaults.join(' ')} ${userClasses}`.trim();
19
+ function mergeClassNames(...classNames: Array<string | undefined>): string {
20
+ return classNames.filter(Boolean).join(' ');
58
21
  }
59
22
 
60
23
  /**
61
24
  * OAuthButton component
62
25
  *
63
- * A headless OAuth button built on top of base-ui's Button primitive.
64
- * Includes sensible defaults (cursor-pointer) while remaining fully
65
- * customizable via className.
66
- *
67
- * Design Philosophy:
68
- * - We add sensible UX defaults (cursor-pointer, proper disabled states)
69
- * - We don't impose visual opinions - you control all styling via className
70
- * - The result is a solid, accessible foundation that looks exactly how you want
26
+ * Built on top of the shared Sublime button foundation so it inherits
27
+ * runtime styling, semantic variants, curated press motion, and loading
28
+ * behavior by default.
71
29
  *
72
30
  * @example
73
31
  * ```tsx
74
- * // Basic usage - cursor-pointer is automatic
75
- * <OAuthButton
76
- * provider="google"
77
- * onClick={signInWithGoogle}
78
- * className="flex items-center gap-3 px-6 py-3 bg-white text-gray-900 rounded-lg font-medium"
79
- * />
80
- *
81
- * // Icon only - pass {null} as children
82
- * <OAuthButton
83
- * provider="google"
84
- * className="p-3 rounded-full hover:bg-white/10"
85
- * >
86
- * {null}
87
- * </OAuthButton>
88
- *
89
- * // Loading state - cursor-not-allowed is automatic
90
- * <OAuthButton provider="google" loading className="opacity-50" />
32
+ * <OAuthButton provider="google" />
33
+ * <OAuthButton provider="discord" variant="primary" loading />
91
34
  * ```
92
35
  */
93
36
  export const OAuthButton = forwardRef<HTMLButtonElement, OAuthButtonProps>(
94
37
  function OAuthButton(props, forwardedRef) {
95
38
  const {
39
+ 'aria-label': ariaLabel,
96
40
  provider,
97
41
  onClick,
98
42
  loading = false,
43
+ loadingAnimation = 'spinner',
44
+ disabled = false,
45
+ variant = 'secondary',
46
+ animation = 'press',
99
47
  className,
100
48
  children,
101
- disabled = false,
102
49
  ...otherProps
103
50
  } = props;
104
51
 
105
- const isDisabled = disabled || loading;
106
- const Icon = getProviderIcon(provider);
52
+ const isIconOnly = children === null;
53
+ const Icon = PROVIDER_ICONS[provider];
107
54
  const defaultText = `Continuar con ${PROVIDER_NAMES[provider]}`;
108
-
109
- const mergedClassName = useMemo(
110
- () => mergeButtonClasses(className, isDisabled),
111
- [className, isDisabled]
112
- );
55
+ const buttonLabel = loading
56
+ ? children ?? 'Conectando...'
57
+ : children ?? defaultText;
58
+ const resolvedAriaLabel = ariaLabel ?? (isIconOnly ? defaultText : undefined);
113
59
 
114
60
  return (
115
- <Button
61
+ <BaseButton
116
62
  ref={forwardedRef}
117
63
  onClick={onClick}
118
- disabled={isDisabled}
119
- aria-busy={loading}
120
- className={mergedClassName}
64
+ disabled={disabled}
65
+ animation={animation}
66
+ variant={variant}
67
+ loading={loading}
68
+ loadingAnimation={loadingAnimation}
69
+ isIconOnly={isIconOnly}
70
+ leadingVisual={<Icon size={20} />}
71
+ className={mergeClassNames('sublime-auth-button', className)}
72
+ data-provider={provider}
73
+ aria-label={resolvedAriaLabel}
121
74
  {...otherProps}
122
75
  >
123
- {loading ? (
124
- <>
125
- <SpinnerIcon />
126
- {children !== null && <span>{children ?? 'Conectando...'}</span>}
127
- </>
128
- ) : (
129
- <>
130
- <Icon />
131
- {children !== null && <span>{children ?? defaultText}</span>}
132
- </>
133
- )}
134
- </Button>
76
+ {buttonLabel}
77
+ </BaseButton>
135
78
  );
136
79
  }
137
- );
138
-
139
- export default OAuthButton;
80
+ );
@@ -0,0 +1,225 @@
1
+ 'use client';
2
+
3
+ import { useInsertionEffect } from 'react';
4
+
5
+ const AUTH_UI_RUNTIME_STYLE_ID = 'sublime-auth-ui-runtime-styles';
6
+
7
+ const AUTH_UI_RUNTIME_STYLES = `
8
+ .sublime-button {
9
+ --sublime-button-gap: var(--sublime-space-3, 0.75rem);
10
+ --sublime-button-padding-x: var(--sublime-space-button-x, 1rem);
11
+ --sublime-button-padding-y: var(--sublime-space-button-y, 0.75rem);
12
+ --sublime-button-height: var(--sublime-size-button-height-md, 2.75rem);
13
+ --sublime-button-radius: var(--sublime-radius-button, 0.875rem);
14
+ --sublime-button-font-family: var(--sublime-font-family-sans, ui-sans-serif, system-ui, sans-serif);
15
+ --sublime-button-font-size: var(--sublime-font-size-sm, 0.875rem);
16
+ --sublime-button-font-weight: var(--sublime-font-weight-medium, 500);
17
+ --sublime-button-line-height: var(--sublime-line-height-tight, 1.15);
18
+ --sublime-button-visual-size: 1.25rem;
19
+ --sublime-button-transition: var(--sublime-transition-button, background-color 160ms cubic-bezier(0.4, 0, 0.2, 1), border-color 160ms cubic-bezier(0.4, 0, 0.2, 1), box-shadow 160ms cubic-bezier(0.4, 0, 0.2, 1), color 160ms cubic-bezier(0.4, 0, 0.2, 1), transform 160ms cubic-bezier(0.4, 0, 0.2, 1));
20
+ --sublime-button-bg: var(--sublime-color-surface-1, #ffffff);
21
+ --sublime-button-bg-hover: var(--sublime-color-surface-1-hover, #f8fafc);
22
+ --sublime-button-bg-active: var(--sublime-color-surface-1-active, #eef2f7);
23
+ --sublime-button-color: var(--sublime-color-text-primary, #111827);
24
+ --sublime-button-color-hover: var(--sublime-color-text-primary, #111827);
25
+ --sublime-button-border: var(--sublime-color-border-primary, #d1d5db);
26
+ --sublime-button-border-hover: var(--sublime-color-border-primary-hover, #9ca3af);
27
+ --sublime-button-border-active: var(--sublime-color-border-primary-active, #6b7280);
28
+ --sublime-button-shadow: var(--sublime-shadow-button, 0 1px 2px rgb(15 23 42 / 0.08));
29
+ --sublime-button-shadow-hover: var(--sublime-shadow-button-hover, 0 8px 20px rgb(15 23 42 / 0.12));
30
+ --sublime-button-shadow-active: var(--sublime-shadow-button-active, inset 0 1px 2px rgb(15 23 42 / 0.14));
31
+ --sublime-button-focus-ring: var(--sublime-color-focus-ring, var(--sublime-color-interactive-accent, #2563eb));
32
+ --sublime-button-focus-ring-offset: var(--sublime-color-focus-ring-offset, var(--sublime-color-surface-0, #ffffff));
33
+ position: relative;
34
+ display: inline-flex;
35
+ min-height: var(--sublime-button-height);
36
+ align-items: center;
37
+ justify-content: center;
38
+ gap: var(--sublime-button-gap);
39
+ padding: var(--sublime-button-padding-y) var(--sublime-button-padding-x);
40
+ border: 1px solid var(--sublime-button-border);
41
+ border-radius: var(--sublime-button-radius);
42
+ background: var(--sublime-button-bg);
43
+ color: var(--sublime-button-color);
44
+ box-shadow: var(--sublime-button-shadow);
45
+ font-family: var(--sublime-button-font-family);
46
+ font-size: var(--sublime-button-font-size);
47
+ font-weight: var(--sublime-button-font-weight);
48
+ line-height: var(--sublime-button-line-height);
49
+ cursor: pointer;
50
+ user-select: none;
51
+ text-decoration: none;
52
+ white-space: nowrap;
53
+ vertical-align: middle;
54
+ isolation: isolate;
55
+ overflow: hidden;
56
+ appearance: none;
57
+ -webkit-tap-highlight-color: transparent;
58
+ transition: var(--sublime-button-transition);
59
+ }
60
+
61
+ .sublime-button:hover:not(:disabled) {
62
+ background: var(--sublime-button-bg-hover);
63
+ color: var(--sublime-button-color-hover);
64
+ border-color: var(--sublime-button-border-hover);
65
+ box-shadow: var(--sublime-button-shadow-hover);
66
+ }
67
+
68
+ .sublime-button:active:not(:disabled) {
69
+ background: var(--sublime-button-bg-active);
70
+ border-color: var(--sublime-button-border-active);
71
+ box-shadow: var(--sublime-button-shadow-active);
72
+ }
73
+
74
+ .sublime-button:focus {
75
+ outline: none;
76
+ }
77
+
78
+ .sublime-button:focus-visible {
79
+ box-shadow:
80
+ 0 0 0 2px var(--sublime-button-focus-ring-offset),
81
+ 0 0 0 4px var(--sublime-button-focus-ring),
82
+ var(--sublime-button-shadow-hover);
83
+ }
84
+
85
+ .sublime-button:disabled {
86
+ cursor: not-allowed;
87
+ opacity: 0.6;
88
+ box-shadow: none;
89
+ }
90
+
91
+ .sublime-button[data-loading='true'] {
92
+ cursor: progress;
93
+ }
94
+
95
+ .sublime-button[data-loading='true']:disabled {
96
+ opacity: 0.78;
97
+ }
98
+
99
+ .sublime-button[data-icon-only='true'] {
100
+ min-width: var(--sublime-button-height);
101
+ padding-inline: calc(var(--sublime-button-padding-y) + 0.125rem);
102
+ }
103
+
104
+ .sublime-button__content {
105
+ display: inline-flex;
106
+ width: 100%;
107
+ align-items: center;
108
+ justify-content: center;
109
+ gap: inherit;
110
+ }
111
+
112
+ .sublime-button__visual,
113
+ .sublime-button__label {
114
+ display: inline-flex;
115
+ align-items: center;
116
+ }
117
+
118
+ .sublime-button__visual {
119
+ flex-shrink: 0;
120
+ justify-content: center;
121
+ min-width: var(--sublime-button-visual-size);
122
+ }
123
+
124
+ .sublime-button__visual svg {
125
+ display: block;
126
+ }
127
+
128
+ .sublime-button__label {
129
+ min-width: 0;
130
+ }
131
+
132
+ .sublime-button--primary {
133
+ --sublime-button-bg: var(--sublime-color-interactive-primary, #111827);
134
+ --sublime-button-bg-hover: var(--sublime-color-interactive-primary-hover, #1f2937);
135
+ --sublime-button-bg-active: var(--sublime-color-interactive-primary-active, #0f172a);
136
+ --sublime-button-color: var(--sublime-color-interactive-primary-text, #f9fafb);
137
+ --sublime-button-color-hover: var(--sublime-color-interactive-primary-text, #f9fafb);
138
+ --sublime-button-border: var(--sublime-color-interactive-primary, #111827);
139
+ --sublime-button-border-hover: var(--sublime-color-interactive-primary-hover, #1f2937);
140
+ --sublime-button-border-active: var(--sublime-color-interactive-primary-active, #0f172a);
141
+ }
142
+
143
+ .sublime-button--secondary {
144
+ --sublime-button-bg: var(--sublime-color-surface-1, #ffffff);
145
+ --sublime-button-bg-hover: var(--sublime-color-surface-1-hover, #f8fafc);
146
+ --sublime-button-bg-active: var(--sublime-color-surface-1-active, #eef2f7);
147
+ --sublime-button-color: var(--sublime-color-text-primary, #111827);
148
+ --sublime-button-color-hover: var(--sublime-color-text-primary, #111827);
149
+ --sublime-button-border: var(--sublime-color-border-primary, #d1d5db);
150
+ --sublime-button-border-hover: var(--sublime-color-border-primary-hover, #9ca3af);
151
+ --sublime-button-border-active: var(--sublime-color-border-primary-active, #6b7280);
152
+ }
153
+
154
+ .sublime-auth-button[data-provider='discord'] .sublime-button__visual {
155
+ color: #5865f2;
156
+ }
157
+
158
+ .sublime-auth-button[data-loading='true'] .sublime-button__visual {
159
+ color: currentColor;
160
+ }
161
+
162
+ @keyframes sublime-button-press-release {
163
+ 0% {
164
+ transform: translateY(1px) scale(0.985);
165
+ box-shadow: var(--sublime-button-shadow-active);
166
+ }
167
+ 100% {
168
+ transform: translateY(0) scale(1);
169
+ box-shadow: var(--sublime-button-shadow);
170
+ }
171
+ }
172
+
173
+ @keyframes sublime-button-spinner {
174
+ from {
175
+ transform: rotate(0deg);
176
+ }
177
+ to {
178
+ transform: rotate(360deg);
179
+ }
180
+ }
181
+
182
+ .sublime-button-pressed {
183
+ transform: translateY(1px) scale(0.985);
184
+ box-shadow: var(--sublime-button-shadow-active);
185
+ }
186
+
187
+ .animate-sublime-button-press-release {
188
+ animation: sublime-button-press-release 180ms var(--sublime-ease-out, cubic-bezier(0, 0, 0.2, 1));
189
+ }
190
+
191
+ .sublime-button__spinner {
192
+ animation: sublime-button-spinner 720ms linear infinite;
193
+ transform-origin: center;
194
+ }
195
+
196
+ @media (prefers-reduced-motion: reduce) {
197
+ .sublime-button,
198
+ .animate-sublime-button-press-release {
199
+ animation-duration: 0.01ms !important;
200
+ animation-iteration-count: 1 !important;
201
+ transition-duration: 0.01ms !important;
202
+ }
203
+
204
+ .sublime-button__spinner {
205
+ animation-duration: 1.6s !important;
206
+ }
207
+ }
208
+ `;
209
+
210
+ export function useAuthUiRuntimeStyles() {
211
+ useInsertionEffect(() => {
212
+ if (typeof document === 'undefined') {
213
+ return;
214
+ }
215
+
216
+ if (document.getElementById(AUTH_UI_RUNTIME_STYLE_ID)) {
217
+ return;
218
+ }
219
+
220
+ const styleElement = document.createElement('style');
221
+ styleElement.id = AUTH_UI_RUNTIME_STYLE_ID;
222
+ styleElement.textContent = AUTH_UI_RUNTIME_STYLES;
223
+ document.head.prepend(styleElement);
224
+ }, []);
225
+ }
package/src/types.ts CHANGED
@@ -1,50 +1,96 @@
1
- import type { ReactNode } from 'react';
1
+ import type {
2
+ ButtonHTMLAttributes,
3
+ MouseEventHandler,
4
+ ReactNode,
5
+ } from 'react';
2
6
 
3
7
  export type OAuthProvider = 'google' | 'discord';
4
8
 
5
- export interface OAuthButtonProps {
9
+ export type ButtonVariant = 'primary' | 'secondary';
10
+
11
+ export type ButtonAnimation = 'press';
12
+
13
+ export type ButtonLoadingAnimation = 'spinner';
14
+
15
+ export interface OAuthButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
6
16
  /**
7
- * The OAuth provider to authenticate with
17
+ * The OAuth provider to authenticate with.
8
18
  */
9
19
  provider: OAuthProvider;
10
20
 
11
21
  /**
12
- * Click handler - typically calls supabase.auth.signInWithOAuth
22
+ * Click handler - typically calls supabase.auth.signInWithOAuth.
13
23
  */
14
- onClick?: () => void;
24
+ onClick?: MouseEventHandler<HTMLButtonElement>;
15
25
 
16
26
  /**
17
- * Loading state - shows spinner when true
27
+ * Loading state - shows the configured loading animation and disables interaction.
18
28
  */
19
29
  loading?: boolean;
20
30
 
21
31
  /**
22
- * Custom className - use Tailwind for styling
23
- * @example "flex items-center gap-2 px-4 py-2 bg-white text-gray-900 rounded-lg"
32
+ * Loading animation shown while `loading` is true.
33
+ * @default 'spinner'
24
34
  */
25
- className?: string;
35
+ loadingAnimation?: ButtonLoadingAnimation;
26
36
 
27
37
  /**
28
- * Button children - defaults to provider name
29
- * Pass `null` for icon-only mode
38
+ * Visual variant of the button.
39
+ * - primary: High emphasis, filled background
40
+ * - secondary: Medium emphasis, bordered (default)
41
+ * @default 'secondary'
30
42
  */
31
- children?: ReactNode;
43
+ variant?: ButtonVariant;
32
44
 
33
45
  /**
34
- * Disable the button
46
+ * Interaction motion applied on press.
47
+ * @default 'press'
35
48
  */
36
- disabled?: boolean;
49
+ animation?: ButtonAnimation;
50
+
51
+ /**
52
+ * Custom className appended after the built-in runtime classes.
53
+ */
54
+ className?: string;
55
+
56
+ /**
57
+ * Button children - defaults to provider name in Spanish.
58
+ * Pass `null` for icon-only mode.
59
+ */
60
+ children?: ReactNode;
37
61
  }
38
62
 
39
63
  export interface OAuthIconsProps {
40
64
  /**
41
- * Icon size in pixels (min: 1)
65
+ * Icon size in pixels (min: 1).
42
66
  * @default 24
43
67
  */
44
68
  size?: number;
45
69
 
46
70
  /**
47
- * Custom className
71
+ * Optional className for icon-specific styling.
48
72
  */
49
73
  className?: string;
50
74
  }
75
+
76
+ export interface UseButtonAnimationResult {
77
+ /**
78
+ * CSS classes to apply for the current animation state.
79
+ */
80
+ animationClassName: string;
81
+
82
+ /**
83
+ * Event handlers to attach to the button.
84
+ */
85
+ eventHandlers: Pick<
86
+ ButtonHTMLAttributes<HTMLButtonElement>,
87
+ | 'onBlur'
88
+ | 'onKeyDown'
89
+ | 'onKeyUp'
90
+ | 'onMouseDown'
91
+ | 'onMouseLeave'
92
+ | 'onMouseUp'
93
+ | 'onTouchEnd'
94
+ | 'onTouchStart'
95
+ >;
96
+ }
@@ -0,0 +1,114 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import type { KeyboardEvent } from 'react';
5
+
6
+ import type { ButtonAnimation, UseButtonAnimationResult } from './types';
7
+
8
+ type AnimationPhase = 'idle' | 'pressed' | 'releasing';
9
+
10
+ const PRESS_RELEASE_DURATION = 180;
11
+
12
+ /**
13
+ * Hook to provide the curated button press animation.
14
+ *
15
+ * Handles mouse, touch, and keyboard interaction so motion feedback stays
16
+ * consistent across the different ways users can activate a button.
17
+ */
18
+ export function useButtonAnimation(
19
+ animation: ButtonAnimation | null = 'press'
20
+ ): UseButtonAnimationResult {
21
+ const [phase, setPhase] = useState<AnimationPhase>('idle');
22
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
23
+
24
+ const clearExistingTimeout = useCallback(() => {
25
+ if (timeoutRef.current) {
26
+ clearTimeout(timeoutRef.current);
27
+ timeoutRef.current = null;
28
+ }
29
+ }, []);
30
+
31
+ useEffect(() => {
32
+ return () => {
33
+ clearExistingTimeout();
34
+ };
35
+ }, [clearExistingTimeout]);
36
+
37
+ const handlePressStart = useCallback(() => {
38
+ if (!animation) {
39
+ return;
40
+ }
41
+
42
+ clearExistingTimeout();
43
+ setPhase('pressed');
44
+ }, [animation, clearExistingTimeout]);
45
+
46
+ const handlePressEnd = useCallback(() => {
47
+ if (!animation || phase !== 'pressed') {
48
+ return;
49
+ }
50
+
51
+ setPhase('releasing');
52
+
53
+ timeoutRef.current = setTimeout(() => {
54
+ setPhase('idle');
55
+ }, PRESS_RELEASE_DURATION);
56
+ }, [animation, phase]);
57
+
58
+ const handleBlur = useCallback(() => {
59
+ clearExistingTimeout();
60
+ setPhase('idle');
61
+ }, [clearExistingTimeout]);
62
+
63
+ const handleKeyDown = useCallback(
64
+ (event: KeyboardEvent<HTMLButtonElement>) => {
65
+ if (event.repeat) {
66
+ return;
67
+ }
68
+
69
+ if (event.key === ' ' || event.key === 'Enter') {
70
+ handlePressStart();
71
+ }
72
+ },
73
+ [handlePressStart]
74
+ );
75
+
76
+ const handleKeyUp = useCallback(
77
+ (event: KeyboardEvent<HTMLButtonElement>) => {
78
+ if (event.key === ' ' || event.key === 'Enter') {
79
+ handlePressEnd();
80
+ }
81
+ },
82
+ [handlePressEnd]
83
+ );
84
+
85
+ const getAnimationClass = (): string => {
86
+ if (!animation) {
87
+ return '';
88
+ }
89
+
90
+ if (phase === 'pressed') {
91
+ return 'sublime-button-pressed';
92
+ }
93
+
94
+ if (phase === 'releasing') {
95
+ return 'animate-sublime-button-press-release';
96
+ }
97
+
98
+ return '';
99
+ };
100
+
101
+ return {
102
+ animationClassName: getAnimationClass(),
103
+ eventHandlers: {
104
+ onBlur: handleBlur,
105
+ onKeyDown: handleKeyDown,
106
+ onKeyUp: handleKeyUp,
107
+ onMouseDown: handlePressStart,
108
+ onMouseLeave: handlePressEnd,
109
+ onMouseUp: handlePressEnd,
110
+ onTouchEnd: handlePressEnd,
111
+ onTouchStart: handlePressStart,
112
+ },
113
+ };
114
+ }