diff --git a/.github/workflows/package-ci.yml b/.github/workflows/package-ci.yml index f753fcac..e8a49da4 100644 --- a/.github/workflows/package-ci.yml +++ b/.github/workflows/package-ci.yml @@ -25,9 +25,6 @@ jobs: - name: Restore package.json # This step is necessary because the previous step may have updated package.json run: git checkout -- package.json packages/*/package.json - - name: Install Playwright Browsers - run: yarn playwright install --with-deps - working-directory: ./packages/tracking-sdk - name: Install Playwright Browsers run: yarn playwright install --with-deps working-directory: ./packages/browser-sdk diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 43d9ceba..54de58ef 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -39,12 +39,12 @@ jobs: path: bucket-docs - name: Copy generated docs to docs repo run: | - rm -rf bucket-docs/sdk-docs - cp -R dist/docs bucket-docs/sdk-docs + rm -rf bucket-docs/sdk + cp -R dist/docs bucket-docs/sdk - name: Commit and push changes run: | cd bucket-docs git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@bucket.co" - git add sdk-docs + git add sdk git commit -m "Update documentation" && git push || echo "No docs changes to commit" diff --git a/.gitignore b/.gitignore index abe7f314..c25132d4 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ junit.xml .next eslint-report.json +bucket.config.json diff --git a/package.json b/package.json index 050c213f..a6a9d970 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,10 @@ "packageManager": "yarn@4.1.1", "devDependencies": { "lerna": "^8.1.3", - "prettier": "^3.3.3", + "prettier": "^3.5.2", "typedoc": "0.27.6", "typedoc-plugin-frontmatter": "^1.1.2", - "typedoc-plugin-markdown": "^4.4.1", + "typedoc-plugin-markdown": "^4.4.2", "typedoc-plugin-mdn-links": "^4.0.7", "typescript": "^5.7.3" } diff --git a/packages/browser-sdk/FEEDBACK.md b/packages/browser-sdk/FEEDBACK.md index f93d2626..6dcdc80f 100644 --- a/packages/browser-sdk/FEEDBACK.md +++ b/packages/browser-sdk/FEEDBACK.md @@ -351,13 +351,7 @@ properties to your page in your CSS `:root`-scope. For example, a dark mode theme might look like this: -```html -image -``` +![image](https://github.com/bucketco/bucket-tracking-sdk/assets/34348/5d579b7b-a830-4530-8b40-864488a8597e) ```css :root { diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 588e9ae4..82090d11 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -2,6 +2,8 @@ Basic client for Bucket.co. If you're using React, you'll be better off with the Bucket React SDK. +Bucket supports feature toggling, tracking feature usage, [collecting feedback](#qualitative-feedback) on features, and [remotely configuring features](#remote-config-beta). + ## Install First find your `publishableKey` under [environment settings](https://app.bucket.co/envs/current/settings/app-environments) in Bucket. @@ -100,15 +102,13 @@ type Configuration = { sseBaseUrl?: "https://livemessaging.bucket.co"; feedback?: undefined; // See FEEDBACK.md enableTracking?: true; // set to `false` to stop sending track events and user/company updates to Bucket servers. Useful when you're impersonating a user - featureOptions?: { - fallbackFeatures?: - | string[] - | Record; // Enable these features if unable to contact bucket.co. Can be a list of feature keys or a record with configuration values - timeoutMs?: number; // Timeout for fetching features (default: 5000ms) - staleWhileRevalidate?: boolean; // Revalidate in the background when cached features turn stale to avoid latency in the UI (default: false) - staleTimeMs?: number; // at initialization time features are loaded from the cache unless they have gone stale. Defaults to 0 which means the cache is disabled. Increase in the case of a non-SPA - expireTimeMs?: number; // In case we're unable to fetch features from Bucket, cached/stale features will be used instead until they expire after `expireTimeMs`. Default is 30 days - }; + fallbackFeatures?: + | string[] + | Record; // Enable these features if unable to contact bucket.co. Can be a list of feature keys or a record with configuration values + timeoutMs?: number; // Timeout for fetching features (default: 5000ms) + staleWhileRevalidate?: boolean; // Revalidate in the background when cached features turn stale to avoid latency in the UI (default: false) + staleTimeMs?: number; // at initialization time features are loaded from the cache unless they have gone stale. Defaults to 0 which means the cache is disabled. Increase this in the case of a non-SPA + expireTimeMs?: number; // In case we're unable to fetch features from Bucket, cached/stale features will be used instead until they expire after `expireTimeMs`. Default is 30 days }; ``` @@ -174,44 +174,7 @@ by down-stream clients, like the React SDK. Note that accessing `isEnabled` on the object returned by `getFeatures` does not automatically generate a `check` event, contrary to the `isEnabled` property on the object returned by `getFeature`. -### Feature Overrides - -You can override feature flags locally for testing purposes using `setFeatureOverride`: - -```ts -// Override a feature to be enabled -bucketClient.setFeatureOverride("huddle", true); - -// Override a feature to be disabled -bucketClient.setFeatureOverride("huddle", false); - -// Remove the override -bucketClient.setFeatureOverride("huddle", null); - -// Get current override value -const override = bucketClient.getFeatureOverride("huddle"); // returns boolean | null -``` - -Feature overrides are persisted in `localStorage` and will be restored when the page is reloaded. - -### Feature Updates - -You can listen for feature updates using `onFeaturesUpdated`: - -```ts -// Register a callback for feature updates -const unsubscribe = bucketClient.onFeaturesUpdated(() => { - console.log("Features were updated"); -}); - -// Later, stop listening for updates -unsubscribe(); -``` - -> [!NOTE] -> Note that the callback may be called even if features haven't actually changed. - -### Remote config +### Remote config (beta) Similar to `isEnabled`, each feature has a `config` property. This configuration is managed from within Bucket. It is managed similar to the way access to features is managed, but instead of the binary `isEnabled` you can have @@ -312,11 +275,12 @@ See details in [Feedback HTTP API](https://docs.bucket.co/reference/http-trackin Event listeners allow for capturing various events occurring in the `BucketClient`. This is useful to build integrations with other system or for various debugging purposes. There are 5 kinds of events: -- FeaturesUpdated -- User -- Company -- Check -- Track +- `configCheck`: Your code used a feature config +- `enabledCheck`: Your code checked whether a specific feature should be enabled +- `featuresUpdated`: Features were updated. Either because they were loaded as part of initialization or because the user/company updated +- `user`: User information updated (similar to the `identify` call used in tracking terminology) +- `company`: Company information updated (sometimes to the `group` call used in tracking terminology) +- `track`: Track event occurred. Use the `on()` method to add an event listener to respond to certain events. See the API reference for details on each hook. diff --git a/packages/browser-sdk/eslint.config.js b/packages/browser-sdk/eslint.config.js index 75295e2d..53da33e1 100644 --- a/packages/browser-sdk/eslint.config.js +++ b/packages/browser-sdk/eslint.config.js @@ -1,3 +1,23 @@ -const base = require("@bucketco/eslint-config/base"); +const base = require("@bucketco/eslint-config"); -module.exports = [...base, { ignores: ["dist/", "example/"] }]; +module.exports = [ + ...base, + { + // Preact projects + files: ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"], + settings: { + react: { + // We only care about marking h() as being a used variable. + pragma: "h", + // We use "react 16.0" to avoid pushing folks to UNSAFE_ methods. + version: "16.0", + }, + }, + rules: { + // Ignore React attributes that are not valid in Preact. + // Alternatively, we could use the preact/compat alias or turn off the rule. + "react/no-unknown-property": ["off"], + }, + }, + { ignores: ["dist/", "example/"] }, +]; diff --git a/packages/browser-sdk/index.html b/packages/browser-sdk/index.html index 80353b29..6768647d 100644 --- a/packages/browser-sdk/index.html +++ b/packages/browser-sdk/index.html @@ -7,7 +7,7 @@ Bucket Browser SDK - +
Loading... diff --git a/packages/browser-sdk/package.json b/packages/browser-sdk/package.json index b9590217..9323ab7b 100644 --- a/packages/browser-sdk/package.json +++ b/packages/browser-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bucketco/browser-sdk", - "version": "3.0.0-alpha.4", + "version": "3.0.0-alpha.6", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { @@ -45,23 +45,22 @@ "@bucketco/eslint-config": "0.0.2", "@bucketco/tsconfig": "0.0.2", "@playwright/test": "^1.49.1", + "@types/js-cookie": "^3.0.6", "@types/node": "^22.12.0", - "@types/webpack": "^5.28.5", - "css-loader": "^6.9.0", - "eslint": "^8.57.0", + "@vitest/coverage-v8": "^2.0.4", + "c8": "~10.1.3", + "eslint": "^9.21.0", + "http-server": "^14.1.1", "jsdom": "^24.1.0", "msw": "^2.3.4", + "nock": "^14.0.1", "postcss": "^8.4.33", - "postcss-loader": "^7.3.4", "postcss-nesting": "^12.0.2", "postcss-preset-env": "^9.3.0", - "prettier": "^3.2.5", - "style-loader": "^3.3.4", + "prettier": "^3.5.2", "typescript": "^5.7.3", "vite": "^5.3.5", "vite-plugin-dts": "^4.0.0-beta.1", - "vitest": "^2.0.4", - "webpack": "^5.89.0", - "webpack-cli": "^5.1.4" + "vitest": "^2.0.4" } } diff --git a/packages/browser-sdk/playwright.config.ts b/packages/browser-sdk/playwright.config.ts index 1e335ac2..b9d9d09c 100644 --- a/packages/browser-sdk/playwright.config.ts +++ b/packages/browser-sdk/playwright.config.ts @@ -34,5 +34,6 @@ export default defineConfig({ // separate port to let the app run alongside the tracking sdk tests command: "npx http-server . -p 8001", timeout: 120 * 1000, + port: 8001, }, }); diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 77e4fa9c..789b80d4 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -307,7 +307,7 @@ export type FeatureRemoteConfig = | { key: undefined; payload: undefined }; /** - * A feature. + * Represents a feature. */ export interface Feature { /** @@ -770,15 +770,21 @@ export class BucketClient { }; } + /** + * @internal + */ setFeatureOverride(key: string, isEnabled: boolean | null) { this.featuresClient.setFeatureOverride(key, isEnabled); } + /** + * @internal + */ getFeatureOverride(key: string): boolean | null { return this.featuresClient.getFeatureOverride(key); } - sendCheckEvent(checkEvent: CheckEvent) { + private sendCheckEvent(checkEvent: CheckEvent) { return this.featuresClient.sendCheckEvent(checkEvent, () => { this.hooks.trigger( checkEvent.action == "check-config" ? "configCheck" : "enabledCheck", diff --git a/packages/browser-sdk/src/feature/featureCache.ts b/packages/browser-sdk/src/feature/featureCache.ts index 70f26159..7e851f22 100644 --- a/packages/browser-sdk/src/feature/featureCache.ts +++ b/packages/browser-sdk/src/feature/featureCache.ts @@ -88,7 +88,7 @@ export class FeatureCache { if (cachedResponseRaw) { cacheData = validateCacheData(JSON.parse(cachedResponseRaw)) ?? {}; } - } catch (e) { + } catch { // ignore errors } @@ -123,7 +123,7 @@ export class FeatureCache { }; } } - } catch (e) { + } catch { // ignore errors } return; diff --git a/packages/browser-sdk/src/feature/features.ts b/packages/browser-sdk/src/feature/features.ts index 8ed47fe9..75bc696a 100644 --- a/packages/browser-sdk/src/feature/features.ts +++ b/packages/browser-sdk/src/feature/features.ts @@ -20,6 +20,7 @@ export type FetchedFeature = { /** * Result of feature flag evaluation. + * Note: does not take local overrides into account. */ isEnabled: boolean; @@ -71,9 +72,11 @@ export type FetchedFeature = { const FEATURES_UPDATED_EVENT = "featuresUpdated"; +/** + * @internal + */ export type FetchedFeatures = Record; -// todo: on next major, come up with a better name for this type. Maybe `LocalFeature`. export type RawFeature = FetchedFeature & { /** * If not null, the result is being overridden locally @@ -328,7 +331,7 @@ export class FeaturesClient { let errorBody = null; try { errorBody = await res.json(); - } catch (e) { + } catch { // ignore } diff --git a/packages/browser-sdk/src/feedback/promptStorage.ts b/packages/browser-sdk/src/feedback/promptStorage.ts index bea0ed9d..905e9da0 100644 --- a/packages/browser-sdk/src/feedback/promptStorage.ts +++ b/packages/browser-sdk/src/feedback/promptStorage.ts @@ -51,7 +51,7 @@ export const getAuthToken = (userId: string) => { channel, token, }; - } catch (e) { + } catch { return undefined; } }; diff --git a/packages/browser-sdk/src/feedback/ui/Button.css b/packages/browser-sdk/src/feedback/ui/Button.css index acf690f3..859a662e 100644 --- a/packages/browser-sdk/src/feedback/ui/Button.css +++ b/packages/browser-sdk/src/feedback/ui/Button.css @@ -21,8 +21,8 @@ 0 1px 1px 0 rgba(0, 0, 0, 0.01); border-radius: var(--bucket-feedback-dialog-border-radius, 6px); transition-duration: 200ms; - transition-property: background-color, border-color, color, opacity, - box-shadow, transform; + transition-property: + background-color, border-color, color, opacity, box-shadow, transform; &.primary { background-color: var( @@ -40,8 +40,8 @@ border-color: var(--bucket-feedback-dialog-primary-border-color, #d8d9df); transition-duration: 200ms; - transition-property: background-color, border-color, color, opacity, - box-shadow, transform; + transition-property: + background-color, border-color, color, opacity, box-shadow, transform; } &:focus { diff --git a/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx b/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx index 2893ebbe..f551045f 100644 --- a/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx +++ b/packages/browser-sdk/src/feedback/ui/FeedbackDialog.tsx @@ -41,12 +41,20 @@ export const FeedbackDialog: FunctionComponent = ({ "idle" | "submitting" | "submitted" >("idle"); + const { isOpen, close } = useDialog({ onClose, initialValue: true }); + + const autoClose = useTimer({ + enabled: position.type === "DIALOG", + initialDuration: INACTIVE_DURATION_MS, + onEnd: close, + }); + const submit = useCallback( async (data: Omit) => { await onSubmit({ ...data, feedbackId }); autoClose.startWithDuration(SUCCESS_DURATION_MS); }, - [feedbackId, onSubmit], + [autoClose, feedbackId, onSubmit], ); const submitScore = useCallback( @@ -59,17 +67,8 @@ export const FeedbackDialog: FunctionComponent = ({ setScoreState("submitted"); } }, - [feedbackId, onSubmit], + [feedbackId, onScoreSubmit], ); - - const { isOpen, close } = useDialog({ onClose, initialValue: true }); - - const autoClose = useTimer({ - enabled: position.type === "DIALOG", - initialDuration: INACTIVE_DURATION_MS, - onEnd: close, - }); - const dismiss = useCallback(() => { autoClose.stop(); close(); @@ -78,25 +77,25 @@ export const FeedbackDialog: FunctionComponent = ({ return ( <> - + + + - - - - - - {anchor && ( -
- )} -
- - ); -}; - -function parseOffset(offsetInput?: Offset["x"] | Offset["y"]) { - if (offsetInput === undefined) return "1rem"; - if (typeof offsetInput === "number") return offsetInput + "px"; - - return offsetInput; -} diff --git a/packages/tracking-sdk/src/feedback/FeedbackForm.css b/packages/tracking-sdk/src/feedback/FeedbackForm.css deleted file mode 100644 index 364e7635..00000000 --- a/packages/tracking-sdk/src/feedback/FeedbackForm.css +++ /dev/null @@ -1,165 +0,0 @@ -.container { - overflow-y: hidden; - transition: max-height 400ms cubic-bezier(0.65, 0, 0.35, 1); -} - -.form { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 10px; - overflow-y: hidden; - max-height: 400px; - transition: opacity 400ms cubic-bezier(0.65, 0, 0.35, 1); -} - -.form-control { - display: flex; - flex-direction: column; - width: 100%; - gap: 8px; - border: none; - padding: 0; - margin: 0; - - font-size: 12px; - color: var(--bucket-feedback-dialog-secondary-color, #787c91); -} - -.form-expanded-content { - width: 100%; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 10px; - transition: opacity 400ms cubic-bezier(0.65, 0, 0.35, 1); - - opacity: 0; - position: absolute; - top: 0; - left: 0; -} - -.title { - color: var(--bucket-feedback-dialog-color, #1e1f24); - font-size: 15px; - font-weight: 400; - line-height: 115%; - text-wrap: balance; - max-width: calc(100% - 20px); - margin-bottom: 6px; - line-height: 1.3; -} - -.dimmed { - opacity: 0.5; -} - -.textarea { - background-color: transparent; - border: 1px solid; - border-color: var(--bucket-feedback-dialog-input-border-color, #d8d9df); - padding: 0.5rem 0.75rem; - border-radius: var(--bucket-feedback-dialog-border-radius, 6px); - transition: border-color 0.2s ease-in-out; - font-family: var( - --bucket-feedback-dialog-font-family, - InterVariable, - Inter, - system-ui, - Open Sans, - sans-serif - ); - line-height: 1.3; - resize: none; - - color: var(--bucket-feedback-dialog-color, #1e1f24); - font-size: 13px; - - &::placeholder { - color: var(--bucket-feedback-dialog-color, #1e1f24); - opacity: 0.36; - } - - &:focus { - outline: none; - border-color: var( - --bucket-feedback-dialog-input-focus-border-color, - #787c91 - ); - } -} - -.score-status-container { - position: relative; - padding-bottom: 6px; - height: 14px; - - > .score-status { - display: flex; - align-items: center; - - position: absolute; - top: 0; - left: 0; - - opacity: 0; - transition: opacity 200ms ease-in-out; - } -} - -.error { - margin: 0; - color: var(--bucket-feedback-dialog-error-color, #e53e3e); - font-size: 0.8125em; - font-weight: 500; -} - -.submitted { - display: flex; - flex-direction: column; - transition: opacity 400ms cubic-bezier(0.65, 0, 0.35, 1); - - position: absolute; - top: 0; - left: 0; - opacity: 0; - pointer-events: none; - width: calc(100% - 56px); - - padding: 0px 28px; - - .submitted-check { - background: var(--bucket-feedback-dialog-submitted-check-color, #fff); - color: var( - --bucket-feedback-dialog-submitted-check-background-color, - #38a169 - ); - height: 24px; - width: 24px; - display: block; - border-radius: 50%; - flex-shrink: 0; - display: flex; - align-items: center; - justify-content: center; - - margin: 16px auto 8px; - } - - .text { - margin: auto auto 16px; - text-align: center; - color: var(--bucket-feedback-dialog-color, #1e1f24); - font-size: var(--bucket-feedback-dialog-font-size, 1rem); - font-weight: 400; - line-height: 130%; - - flex-grow: 1; - max-width: 160px; - } - - > .plug { - flex-grow: 0; - } -} diff --git a/packages/tracking-sdk/src/feedback/FeedbackForm.tsx b/packages/tracking-sdk/src/feedback/FeedbackForm.tsx deleted file mode 100644 index 949ce658..00000000 --- a/packages/tracking-sdk/src/feedback/FeedbackForm.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { FunctionComponent, h } from "preact"; -import { useCallback, useEffect, useRef, useState } from "preact/hooks"; - -import { Check } from "./icons/Check"; -import { CheckCircle } from "./icons/CheckCircle"; -import { Button } from "./Button"; -import { Plug } from "./Plug"; -import { StarRating } from "./StarRating"; -import { - FeedbackScoreSubmission, - FeedbackSubmission, - FeedbackTranslations, -} from "./types"; - -const ANIMATION_SPEED = 400; - -function getFeedbackDataFromForm(el: HTMLFormElement) { - const formData = new FormData(el); - return { - score: Number(formData.get("score")?.toString()), - comment: (formData.get("comment")?.toString() || "").trim(), - }; -} - -type FeedbackFormProps = { - t: FeedbackTranslations; - question: string; - scoreState: "idle" | "submitting" | "submitted"; - openWithCommentVisible: boolean; - onInteraction: () => void; - onSubmit: ( - data: Omit, - ) => Promise | void; - onScoreSubmit: ( - score: Omit, - ) => Promise | void; -}; - -export const FeedbackForm: FunctionComponent = ({ - question, - scoreState, - openWithCommentVisible, - onInteraction, - onSubmit, - onScoreSubmit, - t, -}) => { - const [hasRating, setHasRating] = useState(false); - const [status, setStatus] = useState<"idle" | "submitting" | "submitted">( - "idle", - ); - const [error, setError] = useState(); - const [showForm, setShowForm] = useState(true); - - const handleSubmit: h.JSX.GenericEventHandler = async ( - e, - ) => { - e.preventDefault(); - const data: FeedbackSubmission = { - ...getFeedbackDataFromForm(e.target as HTMLFormElement), - question, - }; - if (!data.score) return; - setError(""); - try { - setStatus("submitting"); - await onSubmit(data); - setStatus("submitted"); - } catch (err) { - setStatus("idle"); - if (err instanceof Error) { - setError(err.message); - } else if (typeof err === "string") { - setError(err); - } else { - setError("Couldn't submit feedback. Please try again."); - } - } - }; - - const containerRef = useRef(null); - const formRef = useRef(null); - const headerRef = useRef(null); - const expandedContentRef = useRef(null); - const submittedRef = useRef(null); - - const transitionToDefault = useCallback(() => { - if (containerRef.current === null) return; - if (headerRef.current === null) return; - if (expandedContentRef.current === null) return; - - containerRef.current.style.maxHeight = - headerRef.current.clientHeight + "px"; - - expandedContentRef.current.style.position = "absolute"; - expandedContentRef.current.style.opacity = "0"; - expandedContentRef.current.style.pointerEvents = "none"; - }, [containerRef, headerRef, expandedContentRef]); - - const transitionToExpanded = useCallback(() => { - if (containerRef.current === null) return; - if (headerRef.current === null) return; - if (expandedContentRef.current === null) return; - - containerRef.current.style.maxHeight = - headerRef.current.clientHeight + // Header height - expandedContentRef.current.clientHeight + // Comment + Button Height - 10 + // Gap height - "px"; - - expandedContentRef.current.style.position = "relative"; - expandedContentRef.current.style.opacity = "1"; - expandedContentRef.current.style.pointerEvents = "all"; - }, [containerRef, headerRef, expandedContentRef]); - - const transitionToSuccess = useCallback(() => { - if (containerRef.current === null) return; - if (formRef.current === null) return; - if (submittedRef.current === null) return; - - formRef.current.style.opacity = "0"; - formRef.current.style.pointerEvents = "none"; - containerRef.current.style.maxHeight = - submittedRef.current.clientHeight + "px"; - - // Fade in "submitted" step once container has resized - setTimeout(() => { - submittedRef.current!.style.position = "relative"; - submittedRef.current!.style.opacity = "1"; - submittedRef.current!.style.pointerEvents = "all"; - setShowForm(false); - }, ANIMATION_SPEED + 10); - }, [formRef, containerRef, submittedRef]); - - useEffect(() => { - if (status === "submitted") { - transitionToSuccess(); - } else if (openWithCommentVisible || hasRating) { - transitionToExpanded(); - } else { - transitionToDefault(); - } - }, [ - transitionToDefault, - transitionToExpanded, - transitionToSuccess, - openWithCommentVisible, - hasRating, - status, - ]); - - return ( -
- - {showForm && ( -
-
-
- {question} -
- { - setHasRating(true); - await onScoreSubmit({ - question, - score: Number(e.currentTarget.value), - }); - }} - /> - - -
- -
-
-