@nextsparkjs/plugin-walkme 0.1.0-beta.104

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.
Files changed (43) hide show
  1. package/.env.example +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +625 -0
  4. package/components/WalkmeBeacon.tsx +64 -0
  5. package/components/WalkmeControls.tsx +111 -0
  6. package/components/WalkmeModal.tsx +144 -0
  7. package/components/WalkmeOverlay.tsx +107 -0
  8. package/components/WalkmeProgress.tsx +53 -0
  9. package/components/WalkmeProvider.tsx +674 -0
  10. package/components/WalkmeSpotlight.tsx +188 -0
  11. package/components/WalkmeTooltip.tsx +152 -0
  12. package/examples/basic-tour.ts +38 -0
  13. package/examples/conditional-tour.ts +56 -0
  14. package/examples/cross-window-tour.ts +54 -0
  15. package/hooks/useTour.ts +52 -0
  16. package/hooks/useTourProgress.ts +38 -0
  17. package/hooks/useTourState.ts +146 -0
  18. package/hooks/useWalkme.ts +52 -0
  19. package/jest.config.cjs +27 -0
  20. package/lib/conditions.ts +113 -0
  21. package/lib/core.ts +323 -0
  22. package/lib/plugin-env.ts +87 -0
  23. package/lib/positioning.ts +172 -0
  24. package/lib/storage.ts +203 -0
  25. package/lib/targeting.ts +186 -0
  26. package/lib/triggers.ts +127 -0
  27. package/lib/validation.ts +122 -0
  28. package/messages/en.json +21 -0
  29. package/messages/es.json +21 -0
  30. package/package.json +18 -0
  31. package/plugin.config.ts +26 -0
  32. package/providers/walkme-context.ts +17 -0
  33. package/tests/lib/conditions.test.ts +172 -0
  34. package/tests/lib/core.test.ts +514 -0
  35. package/tests/lib/positioning.test.ts +43 -0
  36. package/tests/lib/storage.test.ts +232 -0
  37. package/tests/lib/targeting.test.ts +191 -0
  38. package/tests/lib/triggers.test.ts +198 -0
  39. package/tests/lib/validation.test.ts +249 -0
  40. package/tests/setup.ts +52 -0
  41. package/tests/tsconfig.json +32 -0
  42. package/tsconfig.json +47 -0
  43. package/types/walkme.types.ts +316 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * WalkMe Plugin Environment Configuration
3
+ *
4
+ * Uses centralized plugin environment loader from core.
5
+ * Provides type-safe access to WalkMe configuration.
6
+ */
7
+
8
+ import { getPluginEnv } from '@nextsparkjs/core/lib/plugins/env-loader'
9
+
10
+ interface WalkmePluginEnvConfig {
11
+ WALKME_ENABLED?: string
12
+ WALKME_DEBUG?: string
13
+ WALKME_AUTO_START?: string
14
+ WALKME_AUTO_START_DELAY?: string
15
+ WALKME_PERSIST_STATE?: string
16
+ WALKME_ANALYTICS_ENABLED?: string
17
+ }
18
+
19
+ class PluginEnvironment {
20
+ private static instance: PluginEnvironment
21
+ private config: WalkmePluginEnvConfig = {}
22
+ private loaded = false
23
+
24
+ private constructor() {
25
+ this.loadEnvironment()
26
+ }
27
+
28
+ public static getInstance(): PluginEnvironment {
29
+ if (!PluginEnvironment.instance) {
30
+ PluginEnvironment.instance = new PluginEnvironment()
31
+ }
32
+ return PluginEnvironment.instance
33
+ }
34
+
35
+ private loadEnvironment(forceReload: boolean = false): void {
36
+ if (this.loaded && !forceReload) return
37
+
38
+ try {
39
+ const env = getPluginEnv('walkme')
40
+
41
+ this.config = {
42
+ WALKME_ENABLED: env.WALKME_ENABLED || 'true',
43
+ WALKME_DEBUG: env.WALKME_DEBUG || 'false',
44
+ WALKME_AUTO_START: env.WALKME_AUTO_START || 'true',
45
+ WALKME_AUTO_START_DELAY: env.WALKME_AUTO_START_DELAY || '1000',
46
+ WALKME_PERSIST_STATE: env.WALKME_PERSIST_STATE || 'true',
47
+ WALKME_ANALYTICS_ENABLED: env.WALKME_ANALYTICS_ENABLED || 'false',
48
+ }
49
+
50
+ this.loaded = true
51
+ } catch (error) {
52
+ console.error('[WalkMe Plugin] Failed to load environment:', error)
53
+ this.loaded = true
54
+ }
55
+ }
56
+
57
+ public isPluginEnabled(): boolean {
58
+ return this.config.WALKME_ENABLED !== 'false'
59
+ }
60
+
61
+ public isDebugEnabled(): boolean {
62
+ return this.config.WALKME_DEBUG === 'true'
63
+ }
64
+
65
+ public isAutoStartEnabled(): boolean {
66
+ return this.config.WALKME_AUTO_START === 'true'
67
+ }
68
+
69
+ public getAutoStartDelay(): number {
70
+ return parseInt(this.config.WALKME_AUTO_START_DELAY || '1000', 10)
71
+ }
72
+
73
+ public isPersistStateEnabled(): boolean {
74
+ return this.config.WALKME_PERSIST_STATE !== 'false'
75
+ }
76
+
77
+ public isAnalyticsEnabled(): boolean {
78
+ return this.config.WALKME_ANALYTICS_ENABLED === 'true'
79
+ }
80
+
81
+ public reload(): void {
82
+ this.loaded = false
83
+ this.loadEnvironment(true)
84
+ }
85
+ }
86
+
87
+ export const pluginEnv = PluginEnvironment.getInstance()
@@ -0,0 +1,172 @@
1
+ /**
2
+ * WalkMe Positioning Module
3
+ *
4
+ * Wrapper around @floating-ui/react for smart element positioning.
5
+ * Handles auto-flip, scroll tracking, viewport containment, and arrow placement.
6
+ */
7
+
8
+ 'use client'
9
+
10
+ import { useEffect, useRef, useState } from 'react'
11
+ import {
12
+ useFloating,
13
+ autoUpdate,
14
+ offset,
15
+ flip,
16
+ shift,
17
+ arrow,
18
+ type Placement,
19
+ } from '@floating-ui/react'
20
+ import type { StepPosition } from '../types/walkme.types'
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export interface PositionConfig {
27
+ placement: Placement
28
+ offset?: number
29
+ padding?: number
30
+ fallbackPlacements?: Placement[]
31
+ }
32
+
33
+ export interface StepPositioningResult {
34
+ refs: {
35
+ setReference: (el: HTMLElement | null) => void
36
+ setFloating: (el: HTMLElement | null) => void
37
+ }
38
+ floatingStyles: React.CSSProperties
39
+ placement: Placement
40
+ isPositioned: boolean
41
+ arrowRef: React.RefObject<HTMLDivElement | null>
42
+ middlewareData: Record<string, unknown>
43
+ /** Force a position recalculation (useful after scroll/resize settles) */
44
+ update: () => void
45
+ /** Whether floating-ui has had time to stabilize after scroll */
46
+ isStable: boolean
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Utilities
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /** Map StepPosition to @floating-ui Placement */
54
+ export function getPlacementFromPosition(position: StepPosition): Placement {
55
+ switch (position) {
56
+ case 'top':
57
+ return 'top'
58
+ case 'bottom':
59
+ return 'bottom'
60
+ case 'left':
61
+ return 'left'
62
+ case 'right':
63
+ return 'right'
64
+ case 'auto':
65
+ default:
66
+ return 'bottom'
67
+ }
68
+ }
69
+
70
+ /** Get current viewport information */
71
+ export function getViewportInfo(): {
72
+ width: number
73
+ height: number
74
+ scrollX: number
75
+ scrollY: number
76
+ } {
77
+ if (typeof window === 'undefined') {
78
+ return { width: 0, height: 0, scrollX: 0, scrollY: 0 }
79
+ }
80
+ return {
81
+ width: window.innerWidth,
82
+ height: window.innerHeight,
83
+ scrollX: window.scrollX,
84
+ scrollY: window.scrollY,
85
+ }
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Hook
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Hook for positioning a floating step element relative to a target.
94
+ * Wraps @floating-ui/react with sensible defaults for WalkMe steps.
95
+ *
96
+ * IMPORTANT: Does NOT pass targetElement directly to useFloating's
97
+ * `elements.reference`. Instead, sets the reference imperatively after
98
+ * a short delay so that any scrollIntoView triggered by the provider
99
+ * has fully settled. This prevents stale position computation on
100
+ * backward navigation.
101
+ */
102
+ export function useStepPositioning(
103
+ targetElement: HTMLElement | null,
104
+ config: PositionConfig,
105
+ ): StepPositioningResult {
106
+ const arrowRef = useRef<HTMLDivElement>(null)
107
+ const [isStable, setIsStable] = useState(false)
108
+
109
+ const { refs, floatingStyles, placement, isPositioned, middlewareData, update } =
110
+ useFloating({
111
+ placement: config.placement,
112
+ // DO NOT set elements.reference here — we set it imperatively below
113
+ // so floating-ui computes position AFTER scroll has settled.
114
+ whileElementsMounted: autoUpdate,
115
+ middleware: [
116
+ offset(config.offset ?? 8),
117
+ flip({
118
+ fallbackPlacements: config.fallbackPlacements,
119
+ padding: config.padding ?? 8,
120
+ }),
121
+ shift({ padding: config.padding ?? 8 }),
122
+ arrow({ element: arrowRef }),
123
+ ],
124
+ })
125
+
126
+ // Set reference imperatively after scroll settles
127
+ useEffect(() => {
128
+ setIsStable(false)
129
+
130
+ if (!targetElement) {
131
+ refs.setReference(null)
132
+ return
133
+ }
134
+
135
+ // Double rAF ensures any scrollIntoView (even 'instant') has
136
+ // fully reflowed the layout before we hand the element to floating-ui.
137
+ let cancelled = false
138
+ const rafIds = { outer: 0, inner: 0, stable: 0 }
139
+
140
+ rafIds.outer = requestAnimationFrame(() => {
141
+ rafIds.inner = requestAnimationFrame(() => {
142
+ if (cancelled) return
143
+ refs.setReference(targetElement)
144
+ // One more rAF for floating-ui to compute, then mark stable
145
+ rafIds.stable = requestAnimationFrame(() => {
146
+ if (!cancelled) setIsStable(true)
147
+ })
148
+ })
149
+ })
150
+
151
+ return () => {
152
+ cancelled = true
153
+ cancelAnimationFrame(rafIds.outer)
154
+ cancelAnimationFrame(rafIds.inner)
155
+ cancelAnimationFrame(rafIds.stable)
156
+ }
157
+ }, [targetElement, refs])
158
+
159
+ return {
160
+ refs: {
161
+ setReference: refs.setReference,
162
+ setFloating: refs.setFloating,
163
+ },
164
+ floatingStyles,
165
+ placement,
166
+ isPositioned,
167
+ arrowRef,
168
+ middlewareData,
169
+ update,
170
+ isStable,
171
+ }
172
+ }
package/lib/storage.ts ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * WalkMe Storage Module
3
+ *
4
+ * localStorage persistence adapter for tour state.
5
+ * Handles save/load, schema versioning, and graceful SSR fallbacks.
6
+ */
7
+
8
+ import type { TourState, StorageSchema } from '../types/walkme.types'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Constants
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const STORAGE_KEY_PREFIX = 'walkme-state'
15
+ const STORAGE_VERSION = 1
16
+
17
+ /** Build the localStorage key, optionally scoped to a userId */
18
+ function getStorageKey(userId?: string): string {
19
+ return userId ? `${STORAGE_KEY_PREFIX}-${userId}` : STORAGE_KEY_PREFIX
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export interface StorageAdapter {
27
+ load(): StorageSchema | null
28
+ save(state: StorageSchema): void
29
+ reset(): void
30
+ resetTour(tourId: string): void
31
+ getCompletedTours(): string[]
32
+ getSkippedTours(): string[]
33
+ getVisitCount(): number
34
+ incrementVisitCount(): void
35
+ getFirstVisitDate(): string | null
36
+ setFirstVisitDate(date: string): void
37
+ getActiveTour(): TourState | null
38
+ setActiveTour(tour: TourState | null): void
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Utilities
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** Check if localStorage is available */
46
+ export function isStorageAvailable(): boolean {
47
+ if (typeof window === 'undefined') return false
48
+ try {
49
+ const testKey = '__walkme_test__'
50
+ window.localStorage.setItem(testKey, '1')
51
+ window.localStorage.removeItem(testKey)
52
+ return true
53
+ } catch {
54
+ return false
55
+ }
56
+ }
57
+
58
+ /** Create a default empty storage schema */
59
+ function createDefaultSchema(): StorageSchema {
60
+ return {
61
+ version: STORAGE_VERSION,
62
+ completedTours: [],
63
+ skippedTours: [],
64
+ activeTour: null,
65
+ tourHistory: {},
66
+ visitCount: 0,
67
+ firstVisitDate: new Date().toISOString(),
68
+ }
69
+ }
70
+
71
+ /** Migrate storage data from older versions */
72
+ export function migrateStorage(data: unknown): StorageSchema {
73
+ if (!data || typeof data !== 'object') {
74
+ return createDefaultSchema()
75
+ }
76
+
77
+ const record = data as Record<string, unknown>
78
+
79
+ // Version 1 (current) - no migration needed, just validate shape
80
+ return {
81
+ version: STORAGE_VERSION,
82
+ completedTours: Array.isArray(record.completedTours)
83
+ ? (record.completedTours as string[])
84
+ : [],
85
+ skippedTours: Array.isArray(record.skippedTours)
86
+ ? (record.skippedTours as string[])
87
+ : [],
88
+ activeTour: record.activeTour as TourState | null ?? null,
89
+ tourHistory:
90
+ (record.tourHistory as Record<string, TourState>) ?? {},
91
+ visitCount:
92
+ typeof record.visitCount === 'number' ? record.visitCount : 0,
93
+ firstVisitDate:
94
+ typeof record.firstVisitDate === 'string'
95
+ ? record.firstVisitDate
96
+ : new Date().toISOString(),
97
+ }
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Factory
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /** Create a storage adapter backed by localStorage, optionally scoped to a user */
105
+ export function createStorageAdapter(userId?: string): StorageAdapter {
106
+ const available = isStorageAvailable()
107
+ const storageKey = getStorageKey(userId)
108
+
109
+ function read(): StorageSchema {
110
+ if (!available) return createDefaultSchema()
111
+
112
+ try {
113
+ const raw = window.localStorage.getItem(storageKey)
114
+ if (!raw) return createDefaultSchema()
115
+
116
+ const parsed = JSON.parse(raw)
117
+ return migrateStorage(parsed)
118
+ } catch {
119
+ return createDefaultSchema()
120
+ }
121
+ }
122
+
123
+ function write(schema: StorageSchema): void {
124
+ if (!available) return
125
+
126
+ try {
127
+ window.localStorage.setItem(storageKey, JSON.stringify(schema))
128
+ } catch {
129
+ // Storage full or unavailable - silently ignore
130
+ }
131
+ }
132
+
133
+ return {
134
+ load(): StorageSchema | null {
135
+ if (!available) return null
136
+ return read()
137
+ },
138
+
139
+ save(state: StorageSchema): void {
140
+ write(state)
141
+ },
142
+
143
+ reset(): void {
144
+ if (!available) return
145
+ try {
146
+ window.localStorage.removeItem(storageKey)
147
+ } catch {
148
+ // Ignore
149
+ }
150
+ },
151
+
152
+ resetTour(tourId: string): void {
153
+ const state = read()
154
+ state.completedTours = state.completedTours.filter((id) => id !== tourId)
155
+ state.skippedTours = state.skippedTours.filter((id) => id !== tourId)
156
+ const { [tourId]: _, ...remainingHistory } = state.tourHistory
157
+ state.tourHistory = remainingHistory
158
+ if (state.activeTour?.tourId === tourId) {
159
+ state.activeTour = null
160
+ }
161
+ write(state)
162
+ },
163
+
164
+ getCompletedTours(): string[] {
165
+ return read().completedTours
166
+ },
167
+
168
+ getSkippedTours(): string[] {
169
+ return read().skippedTours
170
+ },
171
+
172
+ getVisitCount(): number {
173
+ return read().visitCount
174
+ },
175
+
176
+ incrementVisitCount(): void {
177
+ const state = read()
178
+ state.visitCount += 1
179
+ write(state)
180
+ },
181
+
182
+ getFirstVisitDate(): string | null {
183
+ const state = read()
184
+ return state.firstVisitDate || null
185
+ },
186
+
187
+ setFirstVisitDate(date: string): void {
188
+ const state = read()
189
+ state.firstVisitDate = date
190
+ write(state)
191
+ },
192
+
193
+ getActiveTour(): TourState | null {
194
+ return read().activeTour
195
+ },
196
+
197
+ setActiveTour(tour: TourState | null): void {
198
+ const state = read()
199
+ state.activeTour = tour
200
+ write(state)
201
+ },
202
+ }
203
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * WalkMe Targeting Module
3
+ *
4
+ * DOM element targeting utilities for finding, waiting for,
5
+ * and interacting with target elements for tour steps.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Types
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export interface TargetResult {
13
+ element: HTMLElement | null
14
+ found: boolean
15
+ selector: string
16
+ method: 'css' | 'data-walkme' | 'data-cy'
17
+ }
18
+
19
+ export interface WaitForTargetOptions {
20
+ /** Maximum time to wait in ms (default: 5000) */
21
+ timeout?: number
22
+ /** Polling interval in ms (default: 200) */
23
+ interval?: number
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Element Finding
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Find a target element using various selector strategies.
32
+ *
33
+ * Supports:
34
+ * 1. CSS selectors: `#id`, `.class`, `[attribute="value"]`
35
+ * 2. Data-walkme attribute shorthand: if selector has no CSS special chars,
36
+ * tries `[data-walkme-target="selector"]` first
37
+ * 3. Data-cy attribute: `[data-cy="value"]`
38
+ */
39
+ export function findTarget(selector: string): TargetResult {
40
+ if (typeof window === 'undefined') {
41
+ return { element: null, found: false, selector, method: 'css' }
42
+ }
43
+
44
+ // Try data-walkme-target first if selector looks like a plain name
45
+ if (/^[a-zA-Z0-9_-]+$/.test(selector)) {
46
+ const walkmeEl = document.querySelector<HTMLElement>(
47
+ `[data-walkme-target="${selector}"]`,
48
+ )
49
+ if (walkmeEl) {
50
+ return { element: walkmeEl, found: true, selector, method: 'data-walkme' }
51
+ }
52
+ }
53
+
54
+ // Try as CSS selector
55
+ try {
56
+ const el = document.querySelector<HTMLElement>(selector)
57
+ if (el) {
58
+ const method = selector.includes('data-cy') ? 'data-cy' : 'css'
59
+ return { element: el, found: true, selector, method }
60
+ }
61
+ } catch {
62
+ // Invalid selector - return not found
63
+ }
64
+
65
+ return { element: null, found: false, selector, method: 'css' }
66
+ }
67
+
68
+ /**
69
+ * Wait for a target element to appear in the DOM.
70
+ * Uses MutationObserver for efficient watching.
71
+ */
72
+ export function waitForTarget(
73
+ selector: string,
74
+ options: WaitForTargetOptions = {},
75
+ ): Promise<TargetResult> {
76
+ const { timeout = 5000, interval = 200 } = options
77
+
78
+ return new Promise((resolve) => {
79
+ // Try immediately first
80
+ const immediate = findTarget(selector)
81
+ if (immediate.found) {
82
+ resolve(immediate)
83
+ return
84
+ }
85
+
86
+ if (typeof window === 'undefined') {
87
+ resolve({ element: null, found: false, selector, method: 'css' })
88
+ return
89
+ }
90
+
91
+ let resolved = false
92
+ let observer: MutationObserver | null = null
93
+ let timeoutId: ReturnType<typeof setTimeout> | null = null
94
+ let intervalId: ReturnType<typeof setInterval> | null = null
95
+
96
+ const cleanup = () => {
97
+ resolved = true
98
+ observer?.disconnect()
99
+ if (timeoutId) clearTimeout(timeoutId)
100
+ if (intervalId) clearInterval(intervalId)
101
+ }
102
+
103
+ // Set up timeout
104
+ timeoutId = setTimeout(() => {
105
+ if (!resolved) {
106
+ cleanup()
107
+ resolve({ element: null, found: false, selector, method: 'css' })
108
+ }
109
+ }, timeout)
110
+
111
+ // Poll as a fallback (MutationObserver doesn't catch everything)
112
+ intervalId = setInterval(() => {
113
+ if (resolved) return
114
+ const result = findTarget(selector)
115
+ if (result.found) {
116
+ cleanup()
117
+ resolve(result)
118
+ }
119
+ }, interval)
120
+
121
+ // MutationObserver for immediate detection
122
+ observer = new MutationObserver(() => {
123
+ if (resolved) return
124
+ const result = findTarget(selector)
125
+ if (result.found) {
126
+ cleanup()
127
+ resolve(result)
128
+ }
129
+ })
130
+
131
+ observer.observe(document.body, {
132
+ childList: true,
133
+ subtree: true,
134
+ attributes: true,
135
+ attributeFilter: ['data-walkme-target', 'data-cy', 'id', 'class'],
136
+ })
137
+ })
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Element Utilities
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /** Check if an element is visible (not hidden by CSS) */
145
+ export function isElementVisible(element: HTMLElement): boolean {
146
+ if (typeof window === 'undefined') return false
147
+
148
+ const style = window.getComputedStyle(element)
149
+ return (
150
+ style.display !== 'none' &&
151
+ style.visibility !== 'hidden' &&
152
+ style.opacity !== '0' &&
153
+ element.offsetParent !== null
154
+ )
155
+ }
156
+
157
+ /** Check if an element is within the current viewport */
158
+ export function isElementInViewport(element: HTMLElement): boolean {
159
+ if (typeof window === 'undefined') return false
160
+
161
+ const rect = element.getBoundingClientRect()
162
+ return (
163
+ rect.top >= 0 &&
164
+ rect.left >= 0 &&
165
+ rect.bottom <= window.innerHeight &&
166
+ rect.right <= window.innerWidth
167
+ )
168
+ }
169
+
170
+ /** Scroll the viewport to make an element visible */
171
+ export function scrollToElement(
172
+ element: HTMLElement,
173
+ options: { behavior?: ScrollBehavior; block?: ScrollLogicalPosition } = {},
174
+ ): void {
175
+ if (typeof window === 'undefined') return
176
+
177
+ element.scrollIntoView({
178
+ behavior: options.behavior ?? 'instant',
179
+ block: options.block ?? 'center',
180
+ })
181
+ }
182
+
183
+ /** Get the bounding rect of an element */
184
+ export function getElementRect(element: HTMLElement): DOMRect {
185
+ return element.getBoundingClientRect()
186
+ }