{
+ // istanbul ignore next: babel-loader rewrites this line
+ const [i18n] = useShopifyI18n();
+ return i18n;
+}
diff --git a/src/toolbar/buttons/tour/icon.svg b/src/toolbar/buttons/tour/icon.svg
new file mode 100644
index 00000000..09cdb076
--- /dev/null
+++ b/src/toolbar/buttons/tour/icon.svg
@@ -0,0 +1 @@
+
diff --git a/src/toolbar/buttons/tour/translations/en.json b/src/toolbar/buttons/tour/translations/en.json
new file mode 100644
index 00000000..c16e1da0
--- /dev/null
+++ b/src/toolbar/buttons/tour/translations/en.json
@@ -0,0 +1,4 @@
+{
+ "label": "Tour {appName}",
+ "tooltip": "Take a quick tour of {appName}"
+}
diff --git a/src/tour/Tour.tsx b/src/tour/Tour.tsx
new file mode 100644
index 00000000..10f948e7
--- /dev/null
+++ b/src/tour/Tour.tsx
@@ -0,0 +1,255 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2022 The Pybricks Authors
+
+import { Icon } from '@blueprintjs/core';
+import React, { useCallback, useMemo, useState } from 'react';
+import Joyride, {
+ ACTIONS,
+ CallBackProps,
+ EVENTS,
+ Locale,
+ STATUS,
+ Step,
+ Styles,
+} from 'react-joyride';
+import { useDispatch } from 'react-redux';
+import { useEffectOnce, useLocalStorage, useTernaryDarkMode } from 'usehooks-ts';
+import { Activity, useActivitiesSelectedActivity } from '../activities/hooks';
+import { appName } from '../app/constants';
+import { useSelector } from '../reducers';
+import { tourStart, tourStop } from './actions';
+import { useI18n } from './i18n';
+
+const WelcomeStep = React.memo(function WelcomeStep() {
+ const i18n = useI18n();
+
+ return (
+ <>
+ {i18n.translate('steps.welcome.message', { appName })}
+
+ {i18n.translate('steps.welcome.action', {
+ next: {i18n.translate('next')},
+ })}
+
+ >
+ );
+});
+
+const AddFileStep = React.memo(function AddFileStep() {
+ const i18n = useI18n();
+
+ return (
+
+ {i18n.translate('steps.newFile.message', {
+ icon: ,
+ })}
+
+ );
+});
+
+const BackupFilesStep = React.memo(function BackupFilesStep() {
+ const i18n = useI18n();
+
+ return (
+ <>
+ {i18n.translate('steps.backupFiles.message')}
+
+ {i18n.translate('steps.backupFiles.action', {
+ icon: ,
+ })}
+
+ >
+ );
+});
+
+const FlashPybricksFirmwareStep = React.memo(function FlashPybricksFirmwareStep() {
+ const i18n = useI18n();
+
+ return (
+
+ {i18n.translate('steps.flashPybricksFirmware.message', {
+ appName,
+ })}
+
+ );
+});
+
+const RestoreOfficialFirmwareStep = React.memo(function RestoreOfficialFirmwareStep() {
+ const i18n = useI18n();
+
+ return {i18n.translate('steps.restoreOfficialFirmware.message')}
;
+});
+
+const ConnectToHubStep = React.memo(function ConnectToHubStep() {
+ const i18n = useI18n();
+
+ return {i18n.translate('steps.connectToHub.message')}
;
+});
+
+const DownloadAndRunStep = React.memo(function DownloadAndRunStep() {
+ const i18n = useI18n();
+
+ return (
+
+ {i18n.translate('steps.downloadAndRun.message', {
+ icon: ,
+ })}
+
+ );
+});
+
+const Tour: React.VoidFunctionComponent = () => {
+ const [selectedActivity, setSelectedActivity] = useActivitiesSelectedActivity();
+ const [showOnStartup, setShowOnStartup] = useLocalStorage(
+ 'tour.showOnStartup',
+ true,
+ );
+ const [stepIndex, setStepIndex] = useState(0);
+ const { isRunning } = useSelector((s) => s.tour);
+ const { isDarkMode } = useTernaryDarkMode();
+ const dispatch = useDispatch();
+ const i18n = useI18n();
+
+ const steps = useMemo(
+ () => [
+ {
+ target: '#pb-toolbar-tour-button',
+ content: ,
+ disableBeacon: true,
+ },
+ {
+ target:
+ selectedActivity === Activity.Explorer
+ ? '#pb-explorer-add-button'
+ : '[itemId=pb-activities-explorer-tab]',
+ content: ,
+ disableBeacon: selectedActivity === Activity.Explorer,
+ },
+ {
+ target:
+ selectedActivity === Activity.Explorer
+ ? '#pb-explorer-archive-button'
+ : '[itemId=pb-activities-explorer-tab]',
+ content: ,
+ disableBeacon: selectedActivity === Activity.Explorer,
+ },
+ {
+ target:
+ selectedActivity === Activity.Settings
+ ? '#pb-settings-flash-pybricks-button'
+ : '[itemId=pb-activities-settings-tab]',
+ content: ,
+ disableBeacon: selectedActivity === Activity.Settings,
+ },
+ {
+ target:
+ selectedActivity === Activity.Settings
+ ? '#pb-settings-flash-official-button'
+ : '[itemId=pb-activities-settings-tab]',
+ content: ,
+ disableBeacon: selectedActivity === Activity.Settings,
+ },
+ {
+ target: '#pb-toolbar-bluetooth-button',
+ content: ,
+ disableBeacon: true,
+ },
+ {
+ target: '#pb-toolbar-run-button',
+ content: ,
+ disableBeacon: true,
+ },
+ ],
+ [selectedActivity, i18n],
+ );
+
+ const styles = useMemo(
+ () => ({
+ // colors come from variables.scss
+ options: {
+ // $pybricks-blue
+ primaryColor: '#0088ce',
+ // $pt-dark-text-color / $pt-text-color
+ textColor: isDarkMode ? '#f6f7f9' : '#1c2127',
+ // $pt-dark-app-background-color / $pt-app-background-color
+ backgroundColor: isDarkMode ? '#252a31' : '#f6f7f9',
+ arrowColor: isDarkMode ? '#252a31' : '#f6f7f9',
+ },
+ }),
+ [isDarkMode],
+ );
+
+ const locale = useMemo(
+ () => ({
+ back: i18n.translate('back'),
+ close: i18n.translate('close'),
+ last: i18n.translate('last'),
+ next: i18n.translate('next'),
+ open: i18n.translate('open'),
+ skip: i18n.translate('skip'),
+ }),
+ [i18n],
+ );
+
+ const callback = useCallback(
+ (event: CallBackProps) => {
+ if (event.action === ACTIONS.CLOSE || event.status === STATUS.FINISHED) {
+ dispatch(tourStop());
+ setStepIndex(0);
+ }
+
+ if (event.type === EVENTS.STEP_AFTER) {
+ const nextIndex = stepIndex + (event.action === ACTIONS.PREV ? -1 : 1);
+ const nextStep = steps.at(nextIndex);
+
+ // Some components may not be mounted when the next/back button
+ // is pressed. If this is the case, first target the tab, then
+ // request to activate that tab. When the tab panel is mounted,
+ // the target will automatically update.
+
+ if (typeof nextStep?.target === 'string') {
+ if (nextStep.target.startsWith('[itemId=pb-activities-explorer-')) {
+ setSelectedActivity(Activity.Explorer);
+ } else if (
+ nextStep.target.startsWith('[itemId=pb-activities-settings-')
+ ) {
+ setSelectedActivity(Activity.Settings);
+ }
+ }
+
+ setStepIndex(nextIndex);
+ }
+ },
+ [
+ dispatch,
+ selectedActivity,
+ setSelectedActivity,
+ stepIndex,
+ setStepIndex,
+ steps,
+ ],
+ );
+
+ // automatically show the tour on the first run only
+ useEffectOnce(() => {
+ if (showOnStartup) {
+ dispatch(tourStart());
+ setShowOnStartup(false);
+ }
+ });
+
+ return (
+
+ );
+};
+
+export default Tour;
diff --git a/src/tour/actions.ts b/src/tour/actions.ts
new file mode 100644
index 00000000..71611fd8
--- /dev/null
+++ b/src/tour/actions.ts
@@ -0,0 +1,4 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2022 The Pybricks Authors
+
+export { start as tourStart, stop as tourStop } from './redux/tour';
diff --git a/src/tour/i18n.ts b/src/tour/i18n.ts
new file mode 100644
index 00000000..47ec2790
--- /dev/null
+++ b/src/tour/i18n.ts
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2022 The Pybricks Authors
+
+import { useI18n as useShopifyI18n } from '@shopify/react-i18n';
+import type { TypedI18n } from '../i18n';
+import type translations from './translations/en.json';
+
+export function useI18n(): TypedI18n {
+ // istanbul ignore next: babel-loader rewrites this line
+ const [i18n] = useShopifyI18n();
+ return i18n;
+}
diff --git a/src/tour/reducers.ts b/src/tour/reducers.ts
new file mode 100644
index 00000000..ae6a2d07
--- /dev/null
+++ b/src/tour/reducers.ts
@@ -0,0 +1,6 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2022 The Pybricks Authors
+
+import tour from './redux/tour';
+
+export default tour;
diff --git a/src/tour/redux/tour.ts b/src/tour/redux/tour.ts
new file mode 100644
index 00000000..d41dfc09
--- /dev/null
+++ b/src/tour/redux/tour.ts
@@ -0,0 +1,28 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2022 The Pybricks Authors
+
+import { createSlice } from '@reduxjs/toolkit';
+
+type State = {
+ isRunning: boolean;
+};
+
+const initialState: State = {
+ isRunning: false,
+};
+
+const slice = createSlice({
+ name: 'tour',
+ initialState,
+ reducers: {
+ start(state) {
+ state.isRunning = true;
+ },
+ stop(state) {
+ state.isRunning = false;
+ },
+ },
+});
+
+export const { start, stop } = slice.actions;
+export default slice.reducer;
diff --git a/src/tour/translations/en.json b/src/tour/translations/en.json
new file mode 100644
index 00000000..aa16ecba
--- /dev/null
+++ b/src/tour/translations/en.json
@@ -0,0 +1,33 @@
+{
+ "back": "Back",
+ "close": "Close",
+ "last": "Last",
+ "next": "Next",
+ "open": "Open the dialog",
+ "skip": "Skip",
+ "steps": {
+ "welcome": {
+ "message": "Welcome to {appName}.",
+ "action": "Click {next} to discover key features."
+ },
+ "newFile": {
+ "message": "Click the {icon} button to create a new program."
+ },
+ "backupFiles": {
+ "message": "Don't lose your work.",
+ "action": "Click the {icon} button to backup all of your programs."
+ },
+ "flashPybricksFirmware": {
+ "message": "{appName} requires a special firmware to be flashed to your hub in order to run the program on the hub."
+ },
+ "restoreOfficialFirmware": {
+ "message": "You can always restore the official LEGO® firmware after using the special Pybricks firmware."
+ },
+ "connectToHub": {
+ "message": "One you have flashed the Pybricks firmware, click the Bluetooth button to connect to the hub."
+ },
+ "downloadAndRun": {
+ "message": "After you are connected to the hub, click the {icon} button to download and run your program."
+ }
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index 73cd5b2f..14449bc0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1802,6 +1802,13 @@ __metadata:
languageName: node
linkType: hard
+"@gilbarbara/deep-equal@npm:^0.1.1":
+ version: 0.1.1
+ resolution: "@gilbarbara/deep-equal@npm:0.1.1"
+ checksum: d904761847320587cbb2a76310acc3344d241201e4e6521e9f88b2f4eb7d8a13aed2081fca1cad625ef78390651e5bbfe914ff5d6925a9f40cf305676a9e1f20
+ languageName: node
+ linkType: hard
+
"@humanwhocodes/config-array@npm:^0.9.2":
version: 0.9.5
resolution: "@humanwhocodes/config-array@npm:0.9.5"
@@ -2410,6 +2417,7 @@ __metadata:
react-dev-utils: ^12.0.1
react-dom: ^16.13.1
react-dropzone: ^14.2.2
+ react-joyride: ^2.5.0
react-monaco-editor: ^0.49.0
react-popper: ^2.3.0
react-redux: ^8.0.2
@@ -8000,6 +8008,13 @@ __metadata:
languageName: node
linkType: hard
+"exenv@npm:^1.2.2":
+ version: 1.2.2
+ resolution: "exenv@npm:1.2.2"
+ checksum: a894f3b60ab8419e0b6eec99c690a009c8276b4c90655ccaf7d28faba2de3a6b93b3d92210f9dc5efd36058d44f04098f6bbccef99859151104bfd49939904dc
+ languageName: node
+ linkType: hard
+
"exit@npm:^0.1.2":
version: 0.1.2
resolution: "exit@npm:0.1.2"
@@ -9271,6 +9286,13 @@ __metadata:
languageName: node
linkType: hard
+"is-lite@npm:^0.8.2":
+ version: 0.8.2
+ resolution: "is-lite@npm:0.8.2"
+ checksum: 0ee62cb238c2a044f58d1cd139fb0b48026c407ec8625ee6572b417f164e17ec937f0a0785f466e320749a796c316a3b78dcb4b520f7ddd4b9de38ad5a23d70f
+ languageName: node
+ linkType: hard
+
"is-module@npm:^1.0.0":
version: 1.0.0
resolution: "is-module@npm:1.0.0"
@@ -11478,7 +11500,7 @@ __metadata:
languageName: node
linkType: hard
-"popper.js@npm:^1.14.4, popper.js@npm:^1.16.1":
+"popper.js@npm:^1.14.4, popper.js@npm:^1.16.0, popper.js@npm:^1.16.1":
version: 1.16.1
resolution: "popper.js@npm:1.16.1"
checksum: c56ae5001ec50a77ee297a8061a0221d99d25c7348d2e6bcd3e45a0d0f32a1fd81bca29d46cb0d4bdf13efb77685bd6a0ce93f9eb3c608311a461f945fffedbe
@@ -12699,6 +12721,24 @@ __metadata:
languageName: node
linkType: hard
+"react-floater@npm:^0.7.5":
+ version: 0.7.5
+ resolution: "react-floater@npm:0.7.5"
+ dependencies:
+ deepmerge: ^4.2.2
+ exenv: ^1.2.2
+ is-lite: ^0.8.2
+ popper.js: ^1.16.0
+ react-proptype-conditional-require: ^1.0.4
+ tree-changes: ^0.9.1
+ peerDependencies:
+ prop-types: ^15.7.2
+ react: 15 - 18
+ react-dom: 15 - 18
+ checksum: 72d0ccd15d93e08885e9d5ff59be757df198fa48e796b3ce48138e8ba0f393df4b246c18e4887ad62214607f75041366b34c0d4f0edc7e70e774eb236c377698
+ languageName: node
+ linkType: hard
+
"react-is@npm:^16.13.1, react-is@npm:^16.7.0":
version: 16.13.1
resolution: "react-is@npm:16.13.1"
@@ -12720,6 +12760,26 @@ __metadata:
languageName: node
linkType: hard
+"react-joyride@npm:^2.5.0":
+ version: 2.5.0
+ resolution: "react-joyride@npm:2.5.0"
+ dependencies:
+ deepmerge: ^4.2.2
+ exenv: ^1.2.2
+ is-lite: ^0.8.2
+ react-floater: ^0.7.5
+ react-is: ^16.13.1
+ scroll: ^3.0.1
+ scrollparent: ^2.0.1
+ tree-changes: ^0.9.1
+ peerDependencies:
+ prop-types: ^15.0.0
+ react: 15 - 18
+ react-dom: 15 - 18
+ checksum: 2d15d1b6a6e6e830af0e594b53b5318dd91a3b57f183157404375d1760a36b4f91acf89c69e73fed7ba5d9d0c13bdc23b2ea426a64259635321a4aaa020aae9d
+ languageName: node
+ linkType: hard
+
"react-monaco-editor@npm:^0.49.0":
version: 0.49.0
resolution: "react-monaco-editor@npm:0.49.0"
@@ -12764,6 +12824,13 @@ __metadata:
languageName: node
linkType: hard
+"react-proptype-conditional-require@npm:^1.0.4":
+ version: 1.0.4
+ resolution: "react-proptype-conditional-require@npm:1.0.4"
+ checksum: 78f82d15b2c77c14fd8fbcbbed279850df3a856984aacd519ee6c2162e034b114b8ac47c00157b84ef7c98c0711d933a0177d9d54555629cf381f54341bb0e8f
+ languageName: node
+ linkType: hard
+
"react-redux@npm:^8.0.2":
version: 8.0.2
resolution: "react-redux@npm:8.0.2"
@@ -13385,6 +13452,20 @@ __metadata:
languageName: node
linkType: hard
+"scroll@npm:^3.0.1":
+ version: 3.0.1
+ resolution: "scroll@npm:3.0.1"
+ checksum: e6b045347adace30035073882e6ef2af7e1c81dd611faf3a578ca8cd0d1a3a9da54932dd97ed6fd99c9573351c758fa50e6d8ed4afb5bd3a33794b6c48d25922
+ languageName: node
+ linkType: hard
+
+"scrollparent@npm:^2.0.1":
+ version: 2.0.1
+ resolution: "scrollparent@npm:2.0.1"
+ checksum: 9ff2b29c1233431ebd0910e5f8db4a058836ee0292d1bb18042fbb4d2175c3ed73fa28a71a96074ac6848173185238ad1139bdefc3863be4808df981175cb807
+ languageName: node
+ linkType: hard
+
"select-hose@npm:^2.0.0":
version: 2.0.0
resolution: "select-hose@npm:2.0.0"
@@ -14415,6 +14496,16 @@ __metadata:
languageName: node
linkType: hard
+"tree-changes@npm:^0.9.1":
+ version: 0.9.1
+ resolution: "tree-changes@npm:0.9.1"
+ dependencies:
+ "@gilbarbara/deep-equal": ^0.1.1
+ is-lite: ^0.8.2
+ checksum: 6749f0213dbbfda592326250cdbbc9bb0f03c6c22e042e7089b1c4d6ab2fc1aa5cde0e02d31d5d7a32c901301146a01ed0c9b90a94007798681aa6af76bd6428
+ languageName: node
+ linkType: hard
+
"tryer@npm:^1.0.1":
version: 1.0.1
resolution: "tryer@npm:1.0.1"