@adriansteffan/reactive 0.0.26 → 0.0.27
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.
- package/dist/{mod-Dqf5zajq.js → mod-CbGhKi2f.js} +11238 -10012
- package/dist/mod.d.ts +187 -49
- package/dist/reactive.es.js +26 -16
- package/dist/reactive.umd.js +37 -39
- package/dist/style.css +1 -1
- package/dist/{web-CnAMKrLX.js → web-BFGLx41c.js} +1 -1
- package/dist/{web-6wmUWZwq.js → web-DOFokKz7.js} +1 -1
- package/package.json +7 -2
- package/src/components/canvasblock.tsx +519 -0
- package/src/components/checkdevice.tsx +158 -0
- package/src/components/enterfullscreen.tsx +114 -31
- package/src/components/exitfullscreen.tsx +98 -21
- package/src/components/experimentprovider.tsx +34 -20
- package/src/components/experimentrunner.tsx +387 -0
- package/src/components/index.ts +13 -0
- package/src/components/mobilefilepermission.tsx +12 -19
- package/src/components/plaininput.tsx +7 -8
- package/src/components/prolificending.tsx +10 -4
- package/src/components/quest.tsx +27 -31
- package/src/components/settingsscreen.tsx +770 -0
- package/src/components/text.tsx +48 -3
- package/src/components/upload.tsx +218 -47
- package/src/mod.tsx +3 -12
- package/src/types/array.d.ts +6 -0
- package/src/utils/array.ts +79 -0
- package/src/utils/bytecode.ts +178 -0
- package/src/utils/common.ts +170 -39
- package/template/.env.template +2 -1
- package/template/src/{App.tsx → Experiment.tsx} +4 -4
- package/template/src/main.tsx +4 -4
- package/template/tsconfig.json +1 -0
- package/src/components/experiment.tsx +0 -371
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
|
|
3
|
+
import { ExperimentConfig, Store, Param, now } from '../utils/common';
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState, ComponentType } from 'react';
|
|
5
|
+
import {
|
|
6
|
+
compileTimeline,
|
|
7
|
+
TimelineItem,
|
|
8
|
+
RefinedTrialData,
|
|
9
|
+
ComponentResultData,
|
|
10
|
+
} from '../utils/bytecode';
|
|
11
|
+
|
|
12
|
+
import Upload from './upload';
|
|
13
|
+
import Text from './text';
|
|
14
|
+
import PlainInput from './plaininput';
|
|
15
|
+
import ProlificEnding from './prolificending';
|
|
16
|
+
import Quest from './quest';
|
|
17
|
+
import EnterFullscreen from './enterfullscreen';
|
|
18
|
+
import ExitFullscreen from './exitfullscreen';
|
|
19
|
+
import MicrophoneCheck from './microphonecheck';
|
|
20
|
+
import RequestFilePermission from './mobilefilepermission';
|
|
21
|
+
import CanvasBlock from './canvasblock';
|
|
22
|
+
import CheckDevice from './checkdevice';
|
|
23
|
+
|
|
24
|
+
import VoicerecorderQuestionComponent from './voicerecorder';
|
|
25
|
+
import React from 'react';
|
|
26
|
+
|
|
27
|
+
type ComponentsMap = {
|
|
28
|
+
[key: string]: ComponentType<any>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const defaultComponents: ComponentsMap = {
|
|
32
|
+
Text,
|
|
33
|
+
ProlificEnding,
|
|
34
|
+
EnterFullscreen,
|
|
35
|
+
ExitFullscreen,
|
|
36
|
+
Quest,
|
|
37
|
+
Upload,
|
|
38
|
+
MicrophoneCheck,
|
|
39
|
+
PlainInput,
|
|
40
|
+
RequestFilePermission,
|
|
41
|
+
CanvasBlock,
|
|
42
|
+
CheckDevice,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const defaultCustomQuestions: ComponentsMap = {
|
|
46
|
+
voicerecorder: VoicerecorderQuestionComponent,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
interface RuntimeComponentContent {
|
|
50
|
+
name?: string;
|
|
51
|
+
type: string;
|
|
52
|
+
collectRefreshRate?: boolean;
|
|
53
|
+
hideSettings?: string[] | boolean;
|
|
54
|
+
props?: Record<string, any> | ((store: Store, data: RefinedTrialData[]) => Record<string, any>);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isRuntimeComponentContent(content: any): content is RuntimeComponentContent {
|
|
58
|
+
return typeof content === 'object' && content !== null && typeof content.type === 'string';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function ExperimentRunner({
|
|
62
|
+
timeline,
|
|
63
|
+
config = {
|
|
64
|
+
showProgressBar: false,
|
|
65
|
+
},
|
|
66
|
+
components = {},
|
|
67
|
+
questions = {},
|
|
68
|
+
}: {
|
|
69
|
+
timeline: TimelineItem[];
|
|
70
|
+
config?: ExperimentConfig;
|
|
71
|
+
components?: ComponentsMap;
|
|
72
|
+
questions?: ComponentsMap;
|
|
73
|
+
}) {
|
|
74
|
+
const trialByteCode = useMemo(() => {
|
|
75
|
+
return compileTimeline(timeline);
|
|
76
|
+
}, [timeline]);
|
|
77
|
+
|
|
78
|
+
const [instructionPointer, setInstructionPointer] = useState(0);
|
|
79
|
+
const [data, setData] = useState<RefinedTrialData[]>(() => {
|
|
80
|
+
const urlParams: Record<string, any> = {};
|
|
81
|
+
const searchParams = new URLSearchParams(window.location.search);
|
|
82
|
+
for (const [key, value] of searchParams.entries()) {
|
|
83
|
+
urlParams[key] = value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const registry = Param.getRegistry() || [];
|
|
87
|
+
|
|
88
|
+
const params: Record<string, any> = {};
|
|
89
|
+
|
|
90
|
+
// First add URL params (these will be overwritten by registry entries if there are name collisions)
|
|
91
|
+
for (const [key, value] of Object.entries(urlParams)) {
|
|
92
|
+
params[key] = {
|
|
93
|
+
value,
|
|
94
|
+
registered: false,
|
|
95
|
+
defaultValue: undefined,
|
|
96
|
+
type: undefined,
|
|
97
|
+
description: undefined,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Then add/overwrite with registry entries
|
|
102
|
+
for (const param of registry) {
|
|
103
|
+
params[param.name] = {
|
|
104
|
+
value:
|
|
105
|
+
param.value !== undefined ? param.value : urlParams[param.name],
|
|
106
|
+
registered: true,
|
|
107
|
+
defaultValue: param.defaultValue,
|
|
108
|
+
type: param.type,
|
|
109
|
+
description: param.description,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const initialData: ComponentResultData = {
|
|
114
|
+
index: -1,
|
|
115
|
+
trialNumber: -1,
|
|
116
|
+
start: now(),
|
|
117
|
+
end: now(),
|
|
118
|
+
duration: 0,
|
|
119
|
+
type: '',
|
|
120
|
+
name: '',
|
|
121
|
+
responseData: {
|
|
122
|
+
userAgent: navigator.userAgent,
|
|
123
|
+
params,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return [initialData];
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const [totalTrialsCompleted, setTotalTrialsCompleted] = useState(0);
|
|
131
|
+
const lastTrialEndTimeRef = useRef(now());
|
|
132
|
+
const experimentStoreRef = useRef<Store>({});
|
|
133
|
+
|
|
134
|
+
const componentsMap = { ...defaultComponents, ...components };
|
|
135
|
+
const customQuestionsMap: ComponentsMap = { ...defaultCustomQuestions, ...questions };
|
|
136
|
+
|
|
137
|
+
const progress = useMemo(() => {
|
|
138
|
+
const totalInstructions = trialByteCode.instructions.length;
|
|
139
|
+
return totalInstructions > 0 ? instructionPointer / totalInstructions : 0;
|
|
140
|
+
}, [instructionPointer, trialByteCode.instructions]);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
window.scrollTo(0, 0);
|
|
144
|
+
}, [instructionPointer]);
|
|
145
|
+
|
|
146
|
+
function updateStore(update: Partial<Store>) {
|
|
147
|
+
experimentStoreRef.current = { ...experimentStoreRef.current, ...update };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function next(
|
|
151
|
+
componentResponseData?: object,
|
|
152
|
+
actualStartTime?: number,
|
|
153
|
+
actualEndTime?: number,
|
|
154
|
+
): void {
|
|
155
|
+
const currentTime = now();
|
|
156
|
+
const currentInstruction = trialByteCode.instructions[instructionPointer];
|
|
157
|
+
|
|
158
|
+
// Use the provided startTime if available, otherwise use the lastTrialEndTimeRef
|
|
159
|
+
const startTime = actualStartTime !== undefined ? actualStartTime : lastTrialEndTimeRef.current;
|
|
160
|
+
|
|
161
|
+
const endTime = actualEndTime !== undefined ? actualEndTime : currentTime;
|
|
162
|
+
|
|
163
|
+
if (currentInstruction?.type === 'ExecuteContent') {
|
|
164
|
+
const content = currentInstruction.content;
|
|
165
|
+
if (isRuntimeComponentContent(content)) {
|
|
166
|
+
const trialData: ComponentResultData = {
|
|
167
|
+
index: instructionPointer,
|
|
168
|
+
trialNumber: totalTrialsCompleted + 1,
|
|
169
|
+
start: startTime,
|
|
170
|
+
end: endTime,
|
|
171
|
+
duration: endTime - startTime,
|
|
172
|
+
type: content.type,
|
|
173
|
+
name: content.name ?? '',
|
|
174
|
+
responseData: componentResponseData,
|
|
175
|
+
};
|
|
176
|
+
setData((prevData) => [...prevData, trialData]);
|
|
177
|
+
setTotalTrialsCompleted((prevCount) => prevCount + 1);
|
|
178
|
+
} else {
|
|
179
|
+
console.log(
|
|
180
|
+
"ExecuteContent finished, but content wasn't standard component format:",
|
|
181
|
+
content,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let nextPointer = instructionPointer + 1;
|
|
187
|
+
let foundNextContent = false;
|
|
188
|
+
|
|
189
|
+
while (nextPointer < trialByteCode.instructions.length) {
|
|
190
|
+
const nextInstruction = trialByteCode.instructions[nextPointer];
|
|
191
|
+
|
|
192
|
+
switch (nextInstruction.type) {
|
|
193
|
+
case 'IfGoto':
|
|
194
|
+
if (nextInstruction.cond(experimentStoreRef.current, data)) {
|
|
195
|
+
const markerIndex = trialByteCode.markers[nextInstruction.marker];
|
|
196
|
+
if (markerIndex !== undefined) {
|
|
197
|
+
nextPointer = markerIndex;
|
|
198
|
+
continue;
|
|
199
|
+
} else {
|
|
200
|
+
console.error(`Marker ${nextInstruction.marker} not found`);
|
|
201
|
+
nextPointer++;
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
nextPointer++;
|
|
205
|
+
}
|
|
206
|
+
break;
|
|
207
|
+
|
|
208
|
+
case 'UpdateStore':
|
|
209
|
+
updateStore(nextInstruction.fun(experimentStoreRef.current, data));
|
|
210
|
+
nextPointer++;
|
|
211
|
+
break;
|
|
212
|
+
|
|
213
|
+
case 'ExecuteContent':
|
|
214
|
+
foundNextContent = true;
|
|
215
|
+
lastTrialEndTimeRef.current = now();
|
|
216
|
+
setInstructionPointer(nextPointer);
|
|
217
|
+
return;
|
|
218
|
+
|
|
219
|
+
default:
|
|
220
|
+
console.error('Unknown instruction type encountered:', nextInstruction);
|
|
221
|
+
nextPointer++;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!foundNextContent) {
|
|
227
|
+
setInstructionPointer(nextPointer);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const collectRefreshRate = useCallback((callback: (refreshRate: number | null) => void) => {
|
|
232
|
+
let frameCount = 0;
|
|
233
|
+
const startTime = now();
|
|
234
|
+
const maxDuration = 20000;
|
|
235
|
+
let rafId: number;
|
|
236
|
+
let lastUpdateTime = startTime;
|
|
237
|
+
const updateInterval = 1000;
|
|
238
|
+
|
|
239
|
+
const calculateRefreshRate = () => {
|
|
240
|
+
const currentTime = now();
|
|
241
|
+
const elapsedTime = currentTime - startTime;
|
|
242
|
+
if (elapsedTime > 0) {
|
|
243
|
+
const refreshRate = Math.round((frameCount * 1000) / elapsedTime);
|
|
244
|
+
return refreshRate;
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const cleanup = () => {
|
|
250
|
+
if (rafId) {
|
|
251
|
+
cancelAnimationFrame(rafId);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const countFrames = (timestamp: number) => {
|
|
256
|
+
frameCount++;
|
|
257
|
+
const elapsedTime = timestamp - startTime;
|
|
258
|
+
|
|
259
|
+
// Check if it's time for an update (once per second)
|
|
260
|
+
if (timestamp - lastUpdateTime >= updateInterval) {
|
|
261
|
+
callback(calculateRefreshRate());
|
|
262
|
+
lastUpdateTime = timestamp;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (elapsedTime < maxDuration) {
|
|
266
|
+
rafId = requestAnimationFrame(countFrames);
|
|
267
|
+
} else {
|
|
268
|
+
cleanup();
|
|
269
|
+
callback(calculateRefreshRate());
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
rafId = requestAnimationFrame(countFrames);
|
|
274
|
+
|
|
275
|
+
return cleanup;
|
|
276
|
+
}, []);
|
|
277
|
+
|
|
278
|
+
const TimingWrapper: React.FC<{
|
|
279
|
+
children: React.ReactNode;
|
|
280
|
+
collectRefreshRate?: boolean;
|
|
281
|
+
}> = ({ children, collectRefreshRate: shouldcollect }) => {
|
|
282
|
+
const wrapperStartTimeRef = useRef<number | null>(null);
|
|
283
|
+
const refreshRateRef = useRef<number | null>(null);
|
|
284
|
+
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
wrapperStartTimeRef.current = now();
|
|
287
|
+
|
|
288
|
+
if (shouldcollect) {
|
|
289
|
+
const cleanup = collectRefreshRate((rate) => {
|
|
290
|
+
refreshRateRef.current = rate;
|
|
291
|
+
updateStore({ _reactiveScreenRefreshRate: rate });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return cleanup;
|
|
295
|
+
}
|
|
296
|
+
}, []);
|
|
297
|
+
|
|
298
|
+
const childWithTimingProps = React.Children.map(children, (child) => {
|
|
299
|
+
if (React.isValidElement(child)) {
|
|
300
|
+
const interceptedNext = (
|
|
301
|
+
responseData?: object,
|
|
302
|
+
actualStartTime?: number,
|
|
303
|
+
actualEndTime?: number,
|
|
304
|
+
) => {
|
|
305
|
+
const currentTimeWrapper = now();
|
|
306
|
+
|
|
307
|
+
// Use the timings provided by the component if available, otherwise use the one by the wrapper
|
|
308
|
+
const startTime =
|
|
309
|
+
actualStartTime !== undefined ? actualStartTime : wrapperStartTimeRef.current;
|
|
310
|
+
const endTime = actualEndTime !== undefined ? actualEndTime : currentTimeWrapper;
|
|
311
|
+
|
|
312
|
+
next(responseData, startTime ?? undefined, endTime ?? undefined);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
return React.cloneElement(child, {
|
|
316
|
+
...child.props,
|
|
317
|
+
next: interceptedNext,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return child;
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
return <>{childWithTimingProps}</>;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const currentInstruction = trialByteCode.instructions[instructionPointer];
|
|
327
|
+
|
|
328
|
+
let componentToRender = null;
|
|
329
|
+
if (currentInstruction?.type === 'ExecuteContent') {
|
|
330
|
+
const content = currentInstruction.content;
|
|
331
|
+
if (isRuntimeComponentContent(content)) {
|
|
332
|
+
const Component = componentsMap[content.type];
|
|
333
|
+
|
|
334
|
+
if (Component) {
|
|
335
|
+
const componentProps =
|
|
336
|
+
typeof content.props === 'function'
|
|
337
|
+
? content.props(experimentStoreRef.current, data)
|
|
338
|
+
: content.props || {};
|
|
339
|
+
|
|
340
|
+
componentToRender = (
|
|
341
|
+
<TimingWrapper key={instructionPointer} collectRefreshRate={content.collectRefreshRate}>
|
|
342
|
+
<Component
|
|
343
|
+
next={next}
|
|
344
|
+
updateStore={updateStore}
|
|
345
|
+
store={experimentStoreRef.current}
|
|
346
|
+
data={data}
|
|
347
|
+
{...(content.type === 'Quest' ? { customQuestions: customQuestionsMap } : {})}
|
|
348
|
+
{...componentProps}
|
|
349
|
+
/>
|
|
350
|
+
</TimingWrapper>
|
|
351
|
+
);
|
|
352
|
+
} else {
|
|
353
|
+
console.error(`No component found for type: ${content.type}`);
|
|
354
|
+
componentToRender = <div>Error: Component type "{content.type}" not found.</div>;
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
console.warn('ExecuteContent instruction does not contain standard component data:', content);
|
|
358
|
+
componentToRender = <div>Non-component content encountered.</div>;
|
|
359
|
+
}
|
|
360
|
+
} else if (instructionPointer >= trialByteCode.instructions.length) {
|
|
361
|
+
componentToRender = <></>;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<div className='w-full'>
|
|
366
|
+
<div
|
|
367
|
+
className={` ${
|
|
368
|
+
config.showProgressBar ? '' : 'hidden '
|
|
369
|
+
} px-4 mt-4 sm:mt-12 max-w-2xl mx-auto flex-1 h-6 bg-gray-200 rounded-full overflow-hidden`}
|
|
370
|
+
>
|
|
371
|
+
<div
|
|
372
|
+
className={`h-full bg-gray-200 rounded-full duration-300 ${
|
|
373
|
+
progress > 0 ? ' border-black border-2' : ''
|
|
374
|
+
}`}
|
|
375
|
+
style={{
|
|
376
|
+
width: `${progress * 100}%`,
|
|
377
|
+
backgroundImage: `repeating-linear-gradient(
|
|
378
|
+
-45deg, #E5E7EB, #E5E7EB 10px, #D1D5DB 10px, #D1D5DB 20px
|
|
379
|
+
)`,
|
|
380
|
+
transition: 'width 300ms ease-in-out',
|
|
381
|
+
}}
|
|
382
|
+
/>
|
|
383
|
+
</div>
|
|
384
|
+
{componentToRender}
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { default as Text } from './text';
|
|
2
|
+
export { default as PlainInput } from './plaininput';
|
|
3
|
+
export { default as ProlificEnding } from './prolificending';
|
|
4
|
+
export { default as MicCheck } from './microphonecheck';
|
|
5
|
+
export { default as Quest } from './quest';
|
|
6
|
+
export { default as Upload } from './upload';
|
|
7
|
+
export { default as EnterFullscreen } from './enterfullscreen';
|
|
8
|
+
export { default as ExitFullscreen } from './exitfullscreen';
|
|
9
|
+
export { default as ExperimentProvider } from './experimentprovider';
|
|
10
|
+
export { default as ExperimentRunner } from './experimentrunner';
|
|
11
|
+
export { default as RequestFilePermission } from './mobilefilepermission';
|
|
12
|
+
export { default as CanvasBlock } from './canvasblock';
|
|
13
|
+
export { default as CheckDevice } from './checkdevice';
|
|
@@ -3,28 +3,19 @@ import { useEffect, useState } from 'react';
|
|
|
3
3
|
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
|
|
4
4
|
import { Capacitor } from '@capacitor/core';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
// currently needed for old android verions (<=10) because they need permissions to save data to the documents folder
|
|
8
6
|
export default function RequestFilePermission({ next }: BaseComponentProps) {
|
|
9
|
-
const [
|
|
7
|
+
const [permissionStatus, setPermissionStatus] = useState('checking');
|
|
10
8
|
|
|
11
9
|
useEffect(() => {
|
|
12
|
-
|
|
13
|
-
if (permissionChecking) return;
|
|
10
|
+
if (permissionStatus !== 'checking') return;
|
|
14
11
|
|
|
15
12
|
const checkPermission = async () => {
|
|
16
|
-
// Only run on Android
|
|
17
13
|
if (getPlatform() !== 'mobile' || Capacitor.getPlatform() !== 'android') {
|
|
18
14
|
next({});
|
|
19
15
|
return;
|
|
20
16
|
}
|
|
21
|
-
|
|
22
|
-
setPermissionChecking(true);
|
|
23
17
|
|
|
24
18
|
try {
|
|
25
|
-
// Try to write a test file to check permissions
|
|
26
|
-
// For Android 9-10, this will trigger permission request
|
|
27
|
-
// For Android 11+, this should work with scoped storage
|
|
28
19
|
await Filesystem.writeFile({
|
|
29
20
|
path: 'permission_check.txt',
|
|
30
21
|
data: 'Testing permissions',
|
|
@@ -32,35 +23,37 @@ export default function RequestFilePermission({ next }: BaseComponentProps) {
|
|
|
32
23
|
encoding: Encoding.UTF8
|
|
33
24
|
});
|
|
34
25
|
|
|
35
|
-
|
|
36
26
|
try {
|
|
37
27
|
await Filesystem.deleteFile({
|
|
38
28
|
path: 'permission_check.txt',
|
|
39
29
|
directory: Directory.Documents
|
|
40
30
|
});
|
|
41
31
|
} catch (e) {
|
|
42
|
-
|
|
32
|
+
console.log('Cleanup error:', e);
|
|
43
33
|
}
|
|
44
34
|
|
|
45
|
-
|
|
35
|
+
setPermissionStatus('granted');
|
|
46
36
|
next({});
|
|
47
37
|
} catch (error) {
|
|
48
38
|
console.error('Permission denied or error:', error);
|
|
49
|
-
|
|
50
|
-
setPermissionChecking(false);
|
|
39
|
+
setPermissionStatus('denied');
|
|
51
40
|
}
|
|
52
41
|
};
|
|
53
42
|
|
|
54
43
|
checkPermission();
|
|
55
|
-
}, [next,
|
|
44
|
+
}, [next, permissionStatus]);
|
|
45
|
+
|
|
46
|
+
if (permissionStatus === 'checking') {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
56
49
|
|
|
57
50
|
return (
|
|
58
51
|
<div className="flex flex-col items-center justify-center gap-4 p-6 text-xl mt-16 px-10">
|
|
59
52
|
<p>Storage permission is required to save your data.</p>
|
|
60
53
|
<p>Please grant permission when prompted.</p>
|
|
61
|
-
<p>If there is
|
|
54
|
+
<p>If there is no popup, go to "Settings" {">"} "Apps" {">"} "Name of the App" {">"} "Permissions" {">"} "Storage" {">"} "Allow"</p>
|
|
62
55
|
<button
|
|
63
|
-
onClick={() =>
|
|
56
|
+
onClick={() => setPermissionStatus('checking')}
|
|
64
57
|
className="mt-8 bg-white px-8 py-3 border-2 border-black font-bold text-black text-lg rounded-xl shadow-[2px_2px_0px_rgba(0,0,0,1)] hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-none"
|
|
65
58
|
>
|
|
66
59
|
Try Again
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { BaseComponentProps } from
|
|
2
|
-
import { useState } from
|
|
1
|
+
import { BaseComponentProps } from '../mod';
|
|
2
|
+
import { useState } from 'react';
|
|
3
3
|
|
|
4
4
|
function PlainInput({
|
|
5
5
|
content,
|
|
@@ -8,7 +8,7 @@ function PlainInput({
|
|
|
8
8
|
next,
|
|
9
9
|
updateStore,
|
|
10
10
|
animate = false,
|
|
11
|
-
storeupdate,
|
|
11
|
+
storeupdate,
|
|
12
12
|
placeholder = 'Enter your response here',
|
|
13
13
|
}: BaseComponentProps & {
|
|
14
14
|
content: React.ReactNode;
|
|
@@ -29,7 +29,7 @@ function PlainInput({
|
|
|
29
29
|
if (storeupdate) {
|
|
30
30
|
updateStore(storeupdate(inputValue));
|
|
31
31
|
}
|
|
32
|
-
next({value: inputValue});
|
|
32
|
+
next({ value: inputValue });
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
return (
|
|
@@ -43,11 +43,11 @@ function PlainInput({
|
|
|
43
43
|
|
|
44
44
|
<div className={`mt-8 ${animate ? 'animate-slide-down opacity-0' : ''}`}>
|
|
45
45
|
<input
|
|
46
|
-
type=
|
|
46
|
+
type='text'
|
|
47
47
|
value={inputValue}
|
|
48
48
|
onChange={handleChange}
|
|
49
49
|
placeholder={placeholder}
|
|
50
|
-
className=
|
|
50
|
+
className='w-full px-4 py-3 border-2 border-black rounded-xl text-lg focus:outline-none focus:ring-2 focus:ring-blue-500'
|
|
51
51
|
/>
|
|
52
52
|
</div>
|
|
53
53
|
|
|
@@ -68,5 +68,4 @@ function PlainInput({
|
|
|
68
68
|
);
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
export default PlainInput;
|
|
71
|
+
export default PlainInput;
|
|
@@ -1,17 +1,23 @@
|
|
|
1
|
-
import { BaseComponentProps, getParam } from '../utils/common';
|
|
1
|
+
import { BaseComponentProps, getParam, registerComponentParams } from '../utils/common';
|
|
2
2
|
import Text from '../components/text';
|
|
3
3
|
|
|
4
|
+
registerComponentParams('ProlificEnding', [
|
|
5
|
+
{ name: 'cc', defaultValue: '', type: 'string', description: 'Completion code of the Profilic experiment (more commonly supplied via the code)' },
|
|
6
|
+
])
|
|
7
|
+
|
|
4
8
|
export default function ProlificEnding({
|
|
5
9
|
prolificCode,
|
|
10
|
+
data,
|
|
11
|
+
updateStore
|
|
6
12
|
}: { prolificCode?: string } & BaseComponentProps) {
|
|
7
|
-
let prolificCodeUsed = prolificCode ?? getParam('cc',
|
|
13
|
+
let prolificCodeUsed = prolificCode ?? getParam('cc', '', 'string') ?? null;
|
|
8
14
|
|
|
9
15
|
const content = (
|
|
10
16
|
<div className='flex flex-col items-center'>
|
|
11
17
|
<svg className='w-12 h-12' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
|
|
12
18
|
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
|
|
13
19
|
</svg>
|
|
14
|
-
<p className='
|
|
20
|
+
<p className=''>
|
|
15
21
|
Thank you! Your data has been successfully submitted. <br /> You can go back to Prolific and
|
|
16
22
|
enter the code {prolificCodeUsed} to finish the study. Alternatively, you can click on this
|
|
17
23
|
link:{' '}
|
|
@@ -26,5 +32,5 @@ export default function ProlificEnding({
|
|
|
26
32
|
</div>
|
|
27
33
|
);
|
|
28
34
|
|
|
29
|
-
return <Text content={content} buttonText='' next={() => {}} />;
|
|
35
|
+
return <Text data={data} updateStore={updateStore} content={content} buttonText='' next={() => {}} />;
|
|
30
36
|
}
|
package/src/components/quest.tsx
CHANGED
|
@@ -4,14 +4,11 @@ import { ReactQuestionFactory, Survey, SurveyQuestionElementBase } from 'survey-
|
|
|
4
4
|
import { ContrastLight } from 'survey-core/themes';
|
|
5
5
|
import 'survey-core/defaultV2.min.css';
|
|
6
6
|
|
|
7
|
-
|
|
8
7
|
type ComponentsMap = {
|
|
9
8
|
[key: string]: ComponentType<any>;
|
|
10
9
|
};
|
|
11
10
|
|
|
12
|
-
|
|
13
11
|
const registerCustomQuestion = (name: string, component: ComponentType<any>) => {
|
|
14
|
-
|
|
15
12
|
class CustomQuestionModel extends Question {
|
|
16
13
|
getType() {
|
|
17
14
|
return name;
|
|
@@ -21,13 +18,12 @@ const registerCustomQuestion = (name: string, component: ComponentType<any>) =>
|
|
|
21
18
|
Serializer.addClass(
|
|
22
19
|
name,
|
|
23
20
|
[],
|
|
24
|
-
function() {
|
|
21
|
+
function () {
|
|
25
22
|
return new CustomQuestionModel('');
|
|
26
23
|
},
|
|
27
|
-
'question'
|
|
24
|
+
'question',
|
|
28
25
|
);
|
|
29
26
|
|
|
30
|
-
// Create the question wrapper
|
|
31
27
|
class CustomQuestionWrapper extends SurveyQuestionElementBase {
|
|
32
28
|
constructor(props: any) {
|
|
33
29
|
super(props);
|
|
@@ -50,53 +46,53 @@ const registerCustomQuestion = (name: string, component: ComponentType<any>) =>
|
|
|
50
46
|
}
|
|
51
47
|
}
|
|
52
48
|
|
|
53
|
-
// Register the React component
|
|
54
49
|
ReactQuestionFactory.Instance.registerQuestion(name, (props) => {
|
|
55
50
|
return createElement(CustomQuestionWrapper, props);
|
|
56
51
|
});
|
|
57
52
|
};
|
|
58
53
|
|
|
59
54
|
// The main Quest component
|
|
60
|
-
function Quest({
|
|
61
|
-
next
|
|
55
|
+
function Quest({
|
|
56
|
+
next,
|
|
57
|
+
surveyJson,
|
|
58
|
+
customQuestions = {},
|
|
59
|
+
}: {
|
|
60
|
+
next: (data: object) => void;
|
|
62
61
|
surveyJson: object;
|
|
63
62
|
customQuestions?: ComponentsMap;
|
|
64
63
|
}) {
|
|
65
|
-
|
|
66
|
-
Object.keys(customQuestions).forEach(name => {
|
|
64
|
+
Object.keys(customQuestions).forEach((name) => {
|
|
67
65
|
registerCustomQuestion(name, customQuestions[name]);
|
|
68
66
|
});
|
|
69
67
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
...
|
|
73
|
-
css: { ...defaultV2Css, root: 'sd-root-modern custom-root' }
|
|
68
|
+
const survey = new Model({
|
|
69
|
+
...surveyJson,
|
|
70
|
+
css: { ...defaultV2Css, root: 'sd-root-modern custom-root' },
|
|
74
71
|
});
|
|
75
72
|
survey.applyTheme(ContrastLight);
|
|
76
73
|
|
|
77
|
-
const saveResults = useCallback(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
value: question.value,
|
|
86
|
-
});
|
|
74
|
+
const saveResults = useCallback(
|
|
75
|
+
(sender: any) => {
|
|
76
|
+
const resultData: Record<string, any> = {};
|
|
77
|
+
for (const key in sender.data) {
|
|
78
|
+
const question = sender.getQuestionByName(key);
|
|
79
|
+
if (question) {
|
|
80
|
+
resultData[key] = question.value;
|
|
81
|
+
}
|
|
87
82
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
83
|
+
next(resultData);
|
|
84
|
+
},
|
|
85
|
+
[next],
|
|
86
|
+
);
|
|
91
87
|
|
|
92
88
|
survey.onComplete.add(saveResults);
|
|
93
89
|
|
|
94
90
|
return (
|
|
95
|
-
<div className=
|
|
96
|
-
<div className=
|
|
91
|
+
<div className='max-w-4xl mx-auto px-4'>
|
|
92
|
+
<div className='absolute inset-0 -z-10 h-full w-full bg-white bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] [background-size:16px_16px]' />
|
|
97
93
|
<Survey model={survey} />
|
|
98
94
|
</div>
|
|
99
95
|
);
|
|
100
96
|
}
|
|
101
97
|
|
|
102
|
-
export default Quest;
|
|
98
|
+
export default Quest;
|