diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index 923e3eb923..10449c3728 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -100,6 +100,12 @@ const config: StorybookConfig = { }), ); + // Add support for additional media types + config.module?.rules?.push({ + test: /\.(webp|avif)(\?v=\d+\.\d+\.\d+)?$/, + type: "asset/resource", + }); + // Watch for changes to custom-elements.json to re-render element // attributes whenever the custom elements manifest is generated (config as WebpackConfiguration).devServer = { diff --git a/frontend/docs/docs/stylesheets/extra.css b/frontend/docs/docs/stylesheets/extra.css index f07f9dd85b..fac5a13483 100644 --- a/frontend/docs/docs/stylesheets/extra.css +++ b/frontend/docs/docs/stylesheets/extra.css @@ -728,6 +728,7 @@ h5 { /* .btrix-button is used to indicate a button in the application and is not interactive */ +.btrix-button, .md-button.btrix-button { vertical-align: baseline; border-radius: 0.25rem; @@ -747,6 +748,10 @@ h5 { color: white; } +.btrix-button:not(.md-button) { + border: 1px solid var(--wr-border-color); +} + .md-typeset [type="checkbox"]:checked + .task-list-indicator::before { background-color: var(--wr-task-list-indicator-bg-color); } diff --git a/frontend/docs/docs/user-guide/getting-started.md b/frontend/docs/docs/user-guide/getting-started.md index 2d1dfc9c7e..a089652c17 100644 --- a/frontend/docs/docs/user-guide/getting-started.md +++ b/frontend/docs/docs/user-guide/getting-started.md @@ -26,7 +26,7 @@ You likely have access already if: - [x] You joined an existing org and were given “crawler” permissions. - [x] You are the admin of a self-hosted instance. -Check if you have access by logging in. If you see a [+ Create New...](#accessing-your-dashboard){ .md-button .md-button--primary .btrix-button } button in the dashboard, you’re able to start a site crawl. If you don’t see this button and think that you should, contact your org administrator to update your permissions. +Check if you have access by logging in. If you see a **+ Create New...**{ .md-button .md-button--primary .btrix-button } button in the dashboard, you’re able to start a site crawl. If you don’t see this button and think that you should, contact your org administrator to update your permissions. ## Choose what to archive @@ -53,11 +53,11 @@ Choose one of the following options to tailor this guide to what you’d like to ## Starting the crawl -When you log in, the first page you see is the [org dashboard](overview.md){ data-preview }. If you’ve navigated away to another page, navigate back to **Dashboard**. +When you log in, the first page you see is the org dashboard. If you’ve navigated away to another page, navigate back to **Dashboard**. -You will create a [crawl workflow](crawl-workflows.md){ data-preview } to set up the crawl. +You will create a [crawl workflow](concepts.md#crawl-workflow){ data-preview } to set up the crawl. -1. Tap the [+ Create New...](#accessing-your-dashboard){ .md-button .md-button--primary .btrix-button } button and select **Crawl Workflow**. +1. Tap the **+ Create New...**{ .btrix-button } button and select **Crawl Workflow**. 1. Select the [Crawl Scope](workflow-setup.md#crawl-scope){ data-preview } that best fits what you intend to archive: @@ -101,7 +101,7 @@ You will create a [crawl workflow](crawl-workflows.md){ data-preview } to set up 1. Scroll down and open the **Crawl Limits** section. 1. Set a **Crawl Time Limit** and/or **Crawl Size Limit** according to what is reasonable within your org quotas. -1. When you’re finished entering workflow settings, tap **Run Crawl**{ .md-button .md-button--primary .btrix-button }. +1. When you’re finished entering workflow settings, tap **Run Crawl**{ .btrix-button }. You should now see your new crawl workflow running. Give the crawler a few moments to warm up, and then watch as it crawls the webpage! diff --git a/frontend/docs/docs/user-guide/index.md b/frontend/docs/docs/user-guide/index.md index e9cf0bccc7..a2eec2d6c1 100644 --- a/frontend/docs/docs/user-guide/index.md +++ b/frontend/docs/docs/user-guide/index.md @@ -77,8 +77,8 @@ The user guide documents features, terminology, and settings in the Browsertrix - [Change your email](user-settings.md) - [Change your password](change-password.md#change-password) - [Reset your password](change-password.md#reset-password) - - [Choose your preferred language](user-settings.md) - - [Change how your name appears to team members](user-settings.md) + - [Choose your preferred language](user-settings.md#language) + - [Change how your name appears to team members](user-settings.md#display-name) diff --git a/frontend/docs/docs/user-guide/navigation.md b/frontend/docs/docs/user-guide/navigation.md index 98ae50eae9..400bc84dca 100644 --- a/frontend/docs/docs/user-guide/navigation.md +++ b/frontend/docs/docs/user-guide/navigation.md @@ -20,6 +20,14 @@ You can find links to navigate your org near the top of each page, directly belo --- +## Navigate to the User Guide + +You can access guides from any org page by tapping the **User Guide** button at the top of the page. The user guide will remain open as you navigate between org pages. + +By default, the user guide will open to the side so that you can use Browsertrix and view guides at the same time. To open the user guide in a new tab in your browser, activate the **User Guide** menu and select the _Open in New Tab_ option. + +--- + ## Navigate to Active Crawls If any crawls in your org are active—i.e. visiting pages, waiting to visit pages, or temporarily paused—a link indicating the total number of active crawl runs will appear at the top of every page, next to the **User Guide** button. diff --git a/frontend/docs/mkdocs.yml b/frontend/docs/mkdocs.yml index eb3315ed27..972f56ab73 100644 --- a/frontend/docs/mkdocs.yml +++ b/frontend/docs/mkdocs.yml @@ -27,6 +27,7 @@ theme: - content.tooltips - content.tabs.link - search.suggest + - search.share palette: - media: "(prefers-color-scheme: light)" scheme: webrecorder-light diff --git a/frontend/src/__mocks__/api/collections.js b/frontend/src/__mocks__/api/collections.js new file mode 100644 index 0000000000..59293207ec --- /dev/null +++ b/frontend/src/__mocks__/api/collections.js @@ -0,0 +1,181 @@ +export default { + total: 4, + page: 1, + pageSize: 16, + items: [ + { + id: "fake-fc38-4eab-b857-705b6b1516c9", + name: "Webrecorder Websites", + slug: "webrecorder-websites", + oid: "x_example_org_id_x", + description: + "# Webrecorder Archives\n\nThis collection powers all our archives of our current website [webrecorder.net](https://webrecorder.net) including our [404 page](https://webrecorder.net/404) and [ReplayWeb.page demo](https://webrecorder.net/replaywebpage).\n\nThis collection also highlights the progress we've made on Browsertrix throughout the years; compare the archived [browsertrix.cloud](https://app.browsertrix.com/explore/webrecorder/collections/webrecorder-archives#url=https%3A%2F%2Fbrowsertrix.cloud%2F) landing page to our live [webrecorder.net/browsertrix](https://webrecorder.net/browsertrix/) page!", + caption: "Webrecorder’s web presence throughout the years.", + created: null, + modified: "2026-09-03T20:20:16Z", + lastStatsUpdateStarted: "2026-09-03T20:20:23Z", + crawlCount: 22, + pageCount: 1470, + uniquePageCount: 341, + totalSize: 1172271420, + dateEarliest: "2023-09-28T16:55:40Z", + dateLatest: "2026-09-03T20:19:53.166000Z", + tags: ["webrecorder"], + access: "public", + homeUrl: "https://webrecorder.net/", + homeUrlTs: "2026-06-03T19:20:04.468000Z", + homeUrlPageId: "acda8d04-0c53-4e7f-b8fa-086eab3127b2", + resources: [], + thumbnail: { + name: "fdc12a92-24c8-4c2d-aad1-9be73c4a5d31/images/-thumbnail-29b41745-fc38-4eab-b857-705b6b1516c9-et7retez.jpeg", + path: "https://files.browsertrix.com/crawls/fdc12a92-24c8-4c2d-aad1-9be73c4a5d31/images/-thumbnail-29b41745-fc38-4eab-b857-705b6b1516c9-et7retez.jpeg", + hash: "fe0e35c3ceb76428766d169c9468a3c22b4a48e9227db62eee659fc2296f2ac0", + size: 26451, + mime: "image/jpeg", + originalFilename: + "page-thumbnail_acda8d04-0c53-4e7f-b8fa-086eab3127b2.jpeg", + userid: "fake-8b3e-449e-b8b7-8ee725a9e9a5", + userName: "User Name", + created: "2026-06-03T19:31:31Z", + }, + thumbnailSource: { + url: "https://webrecorder.net/", + urlTs: "2026-06-03T19:20:04.468000Z", + urlPageId: "acda8d04-0c53-4e7f-b8fa-086eab3127b2", + }, + defaultThumbnailName: null, + allowPublicDownload: true, + initialPages: [], + preloadResources: [], + pagesQueryUrl: "", + downloadUrl: null, + topPageHosts: [ + { host: "webrecorder.net", count: 1354 }, + { host: "docs.browsertrix.cloud", count: 34 }, + { host: "replayweb.page", count: 26 }, + { host: "docs.browsertrix.com", count: 22 }, + { host: "crawler.docs.browsertrix.com", count: 11 }, + { host: "browsertrix.cloud", count: 8 }, + { host: "twitter.com", count: 6 }, + { host: "t.co", count: 2 }, + { host: "browsertrix.com", count: 2 }, + { host: "oembed.link", count: 1 }, + ], + indexLastSavedAt: "2026-09-03T20:24:40Z", + indexState: "idle", + indexStats: { + totalUrls: 10808, + dupeUrls: 4673, + conservedSize: 442208093, + totalCrawls: 22, + totalCrawlSize: 1172271420, + removedCrawls: 0, + removedCrawlSize: 0, + updateProgress: 1.0, + uniqueHashes: 2725, + estimatedRedundantSize: 386852193, + }, + runningUpdatesCount: 0, + }, + { + id: "fake-ef0b-4bcf-a03a-004b5a721c35", + name: "Webrecorder Social Media", + slug: "webrecorder-social-media", + oid: "x_example_org_id_x", + description: + "# Webrecorder Social Media\n\nThis collection includes Browsertrix captures of Webrecorder's social media presence, demonstrating the effectiveness of Browsertrix's [built-in automated page behaviors](https://docs.browsertrix.com/user-guide/behaviors/#platform-specific) and [smart scoping](https://docs.browsertrix.com/user-guide/workflow-setup/#use-smart-scoping-rules) for social media sites, as well as the ability to crawl behind login walls using [browser profiles](https://docs.browsertrix.com/user-guide/browser-profiles/browser-profiles-overview/).", + caption: + "Webrecorder's social media presence, archived with Browsertrix.", + created: "2026-08-31T21:22:05Z", + modified: "2026-09-01T18:02:55Z", + lastStatsUpdateStarted: "2026-09-01T00:05:37Z", + crawlCount: 1, + pageCount: 8, + uniquePageCount: 8, + totalSize: 45471339, + dateEarliest: "2026-09-01T00:04:20.941000Z", + dateLatest: "2026-09-01T00:05:08.382000Z", + tags: [], + access: "unlisted", + homeUrl: "https://www.instagram.com/webrecorder/", + homeUrlTs: "2026-09-01T00:04:20.941000Z", + homeUrlPageId: "21ac772d-40a4-4594-89df-1d678053c170", + resources: [], + thumbnail: { + name: "fdc12a92-24c8-4c2d-aad1-9be73c4a5d31/images/-thumbnail-6eb71aa2-ef0b-4bcf-a03a-004b5a721c35-4zblpne3.jpeg", + path: "https://files.browsertrix.com/crawls/fdc12a92-24c8-4c2d-aad1-9be73c4a5d31/images/-thumbnail-6eb71aa2-ef0b-4bcf-a03a-004b5a721c35-4zblpne3.jpeg", + hash: "2be515acb3116de92a6f2833832c6b7d41120e81d5ec8e44525afac446b5ce04", + size: 28757, + mime: "image/jpeg", + originalFilename: + "page-thumbnail_21ac772d-40a4-4594-89df-1d678053c170.jpeg", + userid: "fake-9378-4cb5-93be-e3a4e74a39c2", + userName: "User Name", + created: "2026-09-01T16:15:21Z", + }, + thumbnailSource: { + url: "https://www.instagram.com/webrecorder/", + urlTs: "2026-09-01T00:04:20.941000Z", + urlPageId: "21ac772d-40a4-4594-89df-1d678053c170", + }, + defaultThumbnailName: null, + allowPublicDownload: true, + initialPages: [], + preloadResources: [], + pagesQueryUrl: "", + downloadUrl: null, + topPageHosts: [{ host: "www.instagram.com", count: 8 }], + indexLastSavedAt: "2026-09-01T00:10:13Z", + indexState: "idle", + indexStats: { + totalUrls: 984, + dupeUrls: 356, + conservedSize: 53110651, + totalCrawls: 1, + totalCrawlSize: 45471339, + removedCrawls: 0, + removedCrawlSize: 0, + updateProgress: 0.0, + uniqueHashes: 628, + estimatedRedundantSize: 0, + }, + runningUpdatesCount: 0, + }, + { + id: "fake-efb9-4613-bcc7-49dc4810fb01", + name: "Private", + slug: "private", + oid: "x_example_org_id_x", + description: null, + caption: "", + created: "2025-07-28T19:20:35Z", + modified: "2025-07-28T19:20:43Z", + lastStatsUpdateStarted: null, + crawlCount: 1, + pageCount: 1, + uniquePageCount: 1, + totalSize: 7744084, + dateEarliest: "2025-07-28T19:15:50.934000Z", + dateLatest: "2025-07-28T19:15:50.934000Z", + tags: [], + access: "private", + homeUrl: null, + homeUrlTs: null, + homeUrlPageId: null, + resources: [], + thumbnail: null, + thumbnailSource: null, + defaultThumbnailName: "thumbnail-cyan", + allowPublicDownload: true, + initialPages: [], + preloadResources: [], + pagesQueryUrl: "", + downloadUrl: null, + topPageHosts: [{ host: "webrecorder.net", count: 1 }], + indexLastSavedAt: null, + indexState: null, + indexStats: null, + runningUpdatesCount: 0, + }, + ], +}; diff --git a/frontend/src/components/layout/app-bar.ts b/frontend/src/components/layout/app-bar.ts index 4b42df8331..cad9e81cad 100644 --- a/frontend/src/components/layout/app-bar.ts +++ b/frontend/src/components/layout/app-bar.ts @@ -16,6 +16,10 @@ import { import { ORG_NAME_MAX_LENGTH } from "@/types/org"; import { type UserOrg } from "@/types/user"; import { type RouteName, type ViewState } from "@/utils/APIRouter"; +import { + completeOnboardingStep, + OnboardingStep, +} from "@/utils/onboarding/onboardingEvents"; import { urlForName } from "@/utils/router"; import { AppStateService } from "@/utils/state"; import { tw } from "@/utils/tailwind"; @@ -267,7 +271,13 @@ export class AppBar extends BtrixElement { ${msg("Open to Side")} - + { + completeOnboardingStep(OnboardingStep.OpenUserGuide); + }} + > ${msg("Open in New Tab")} @@ -286,6 +296,8 @@ export class AppBar extends BtrixElement { } private readonly showUserGuide = () => { + completeOnboardingStep(OnboardingStep.OpenUserGuide); + this.dispatchEvent( new CustomEvent( "btrix-user-guide-show", diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index c8463fbb52..a639839904 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -8,6 +8,7 @@ import "./dialog"; import "./link"; import "./navigation"; import "./overflow-scroll"; +import "./popover"; import "./tab-group"; import "./tab-list"; import "./url-input"; @@ -36,7 +37,6 @@ import("./meter"); import("./numbered-list"); import("./overflow-dropdown"); import("./pagination"); -import("./popover"); import("./popover-menu"); import("./pw-strength-alert"); import("./prose"); diff --git a/frontend/src/constants/docs-email.ts b/frontend/src/constants/docs-email.ts new file mode 100644 index 0000000000..5cd695625f --- /dev/null +++ b/frontend/src/constants/docs-email.ts @@ -0,0 +1 @@ +export const docsEmail = "docs-feedback@webrecorder.net"; diff --git a/frontend/src/features/collections/collections-grid.ts b/frontend/src/features/collections/collections-grid.ts index 6660c3e20b..5aa561c250 100644 --- a/frontend/src/features/collections/collections-grid.ts +++ b/frontend/src/features/collections/collections-grid.ts @@ -45,7 +45,7 @@ export class CollectionsGrid extends BtrixElement { pagination!: Node[]; render() { - const gridClassNames = tw`grid flex-1 grid-cols-1 gap-10 md:grid-cols-2 lg:grid-cols-3`; + const gridClassNames = tw`grid flex-1 grid-cols-1 gap-10 @md/org:grid-cols-2 @lg/org:grid-cols-3`; if (!this.collections || !this.slug) { const thumb = html` @@ -56,7 +56,7 @@ export class CollectionsGrid extends BtrixElement { `; return html` -
${thumb}${thumb}${thumb}${thumb}
+
${thumb}${thumb}${thumb}
`; } @@ -168,7 +168,11 @@ export class CollectionsGrid extends BtrixElement { node.nodeName !== "SLOT").length && + tw`mt-10`, + )} name="pagination" > `; diff --git a/frontend/src/features/crawl-workflows/workflow-editor.ts b/frontend/src/features/crawl-workflows/workflow-editor.ts index c85cd44fbb..c59e55b265 100644 --- a/frontend/src/features/crawl-workflows/workflow-editor.ts +++ b/frontend/src/features/crawl-workflows/workflow-editor.ts @@ -134,6 +134,10 @@ import { makeCurrentTargetHandler, stopProp } from "@/utils/events"; import { formValidator, maxLengthValidator } from "@/utils/form"; import { isNotEqual } from "@/utils/is-not-equal"; import localize from "@/utils/localize"; +import { + completeOnboardingStep, + OnboardingStep, +} from "@/utils/onboarding/onboardingEvents"; import { isArchivingDisabled } from "@/utils/orgs"; import { pluralOf } from "@/utils/pluralize"; import slugifyStrict from "@/utils/slugify"; @@ -3576,6 +3580,8 @@ https://archiveweb.page/images/${"logo.svg"}`} id: "workflow-created-status", }); + completeOnboardingStep(OnboardingStep.CreateWorkflow); + this.navigate.to( `${this.navigate.orgBasePath}/workflows/${this.configId || data.id}/${ crawlId && !storageQuotaReached && !executionMinutesQuotaReached diff --git a/frontend/src/features/org/org-status-banner.ts b/frontend/src/features/org/org-status-banner.ts index 5f5a74ed5a..7a4fa91472 100644 --- a/frontend/src/features/org/org-status-banner.ts +++ b/frontend/src/features/org/org-status-banner.ts @@ -53,7 +53,7 @@ const iconForVariant = ( }; // show banner as warning if <= this many days of trial is left -const TRIAL_DAYS_LEFT_SHOW_WARNING = 4; +export const TRIAL_DAYS_LEFT_SHOW_WARNING = 4; @customElement("btrix-org-status-banner") @localized() diff --git a/frontend/src/index.ts b/frontend/src/index.ts index c00c0d4fb1..5326cdefba 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -382,10 +382,18 @@ export class App extends BtrixElement { AppStateService.updateUserGuideOpen(false)} + @sl-hide=${() => { + AppStateService.updateUserGuideOpen(false); + }} + @sl-after-hide=${() => { + if (this.userGuidePath.startsWith("?q=")) { + // Reset search + this.userGuidePath = ""; + } + }} > diff --git a/frontend/src/layouts/page.ts b/frontend/src/layouts/page.ts index d8a31344fa..90bf2d6393 100644 --- a/frontend/src/layouts/page.ts +++ b/frontend/src/layouts/page.ts @@ -10,15 +10,17 @@ import { tw } from "@/utils/tailwind"; export function pageHeading({ content, level = 2, + classNames, }: { content?: string | TemplateResult | typeof nothing; level?: 2 | 3 | 4; + classNames?: typeof tw | string; }) { const tag = unsafeStatic(`h${level}`); - const classNames = tw`min-w-0 text-base font-medium leading-8 lg:text-lg`; + const classes = tw`min-w-0 text-base font-medium leading-8 lg:text-lg`; return staticHtml` - <${tag} class=${classNames}> + <${tag} class=${clsx(classes, classNames)}> ${ content || html`` diff --git a/frontend/src/pages/invite/accept.ts b/frontend/src/pages/invite/accept.ts index b5ccb896b6..27e1953b3f 100644 --- a/frontend/src/pages/invite/accept.ts +++ b/frontend/src/pages/invite/accept.ts @@ -236,6 +236,10 @@ export class AcceptInvite extends BtrixElement { } else { const user = await this._getCurrentUser(); + AppStateService.partialUpdateOnboarding({ + orgId: org.id, + showOnboarding: true, + }); AppStateService.updateUser(formatAPIUser(user), org.slug); await this.updateComplete; diff --git a/frontend/src/pages/invite/join.ts b/frontend/src/pages/invite/join.ts index de95841df7..512863b1e1 100644 --- a/frontend/src/pages/invite/join.ts +++ b/frontend/src/pages/invite/join.ts @@ -9,8 +9,10 @@ import { BtrixElement } from "@/classes/BtrixElement"; import type { SignUpSuccessDetail } from "@/features/accounts/sign-up-form"; import type { OrgUpdatedDetail } from "@/pages/invite/ui/org-form"; import { OrgTab, RouteNamespace } from "@/routes"; +import { type Onboarding } from "@/types/onboarding"; import type { UserInfo, UserOrg, UserOrgInviteInfo } from "@/types/user"; import AuthService, { type LoggedInEventDetail } from "@/utils/AuthService"; +import { AppStateService } from "@/utils/state"; import "./ui/org-form"; @@ -33,6 +35,21 @@ export class Join extends BtrixElement { task: async ([token, email]) => { if (!token || !email) throw new Error("Missing args"); const inviteInfo = await this._getInviteInfo({ token, email }); + + if (inviteInfo) { + const onboarding: Onboarding = isUserInfo(inviteInfo) + ? { + orgId: this.orgId || "", + showOnboarding: true, + } + : { + orgId: inviteInfo.oid, + showOnboarding: true, + }; + + AppStateService.partialUpdateOnboarding(onboarding); + } + return inviteInfo; }, args: () => [this.token, this.email] as const, diff --git a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts index e1b13c5c4d..61dee535fe 100644 --- a/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts +++ b/frontend/src/pages/org/archived-item-detail/archived-item-detail.ts @@ -53,6 +53,10 @@ import { renderName, } from "@/utils/crawler"; import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; +import { + completeOnboardingStep, + OnboardingStep, +} from "@/utils/onboarding/onboardingEvents"; import { isArchivingDisabled } from "@/utils/orgs"; import { pluralOf } from "@/utils/pluralize"; import { richText } from "@/utils/rich-text"; @@ -968,6 +972,11 @@ export class ArchivedItemDetail extends BtrixElement { replayBase="/replay/" noSandbox="true" noCache="true" + @rwp-url-change=${() => { + if (this.itemType === "crawl") { + completeOnboardingStep(OnboardingStep.ReplayItem); + } + }} > ` : html` diff --git a/frontend/src/pages/org/dashboard.ts b/frontend/src/pages/org/dashboard.ts index 36159a7b0d..31a45fee6c 100644 --- a/frontend/src/pages/org/dashboard.ts +++ b/frontend/src/pages/org/dashboard.ts @@ -1,4 +1,4 @@ -import { localized, msg } from "@lit/localize"; +import { localized, msg, str } from "@lit/localize"; import { Task, TaskStatus } from "@lit/task"; import type { SlChangeEvent, @@ -7,43 +7,62 @@ import type { } from "@shoelace-style/shoelace"; import clsx from "clsx"; import { html, nothing, type PropertyValues, type TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators.js"; +import { customElement, property, query, state } from "lit/decorators.js"; +import { guard } from "lit/directives/guard.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { when } from "lit/directives/when.js"; import queryString from "query-string"; +import { dashboardHeading } from "./dashboard/layouts/dashboardHeading"; +import { primaryWithAside } from "./dashboard/layouts/primaryWithAside"; +import { docsFeedback } from "./dashboard/templates/docsFeedback"; +import { generalGuides } from "./dashboard/templates/generalGuides"; +import { onboardingChecklist } from "./dashboard/templates/onboardingChecklist"; +import { resourcesList } from "./dashboard/templates/resourcesList"; + import type { SelectNewDialogEvent } from "."; import { BtrixElement } from "@/classes/BtrixElement"; +import { type Dialog } from "@/components/ui/dialog"; import { parsePage, type PageChangeEvent } from "@/components/ui/pagination"; +import { docsEmail } from "@/constants/docs-email"; +import { type BtrixUserGuideShowEvent } from "@/events/btrix-user-guide-show"; import { storageColorClasses } from "@/features/meters/storage/colors"; +import { + OrgStatusBanner, + TRIAL_DAYS_LEFT_SHOW_WARNING, +} from "@/features/org/org-status-banner"; import { pageHeading } from "@/layouts/page"; import { pageHeader } from "@/layouts/pageHeader"; import type { CollectionSavedEvent } from "@/pages/org/collection-detail/types"; -import { RouteNamespace } from "@/routes"; +import { type Tab as SettingsTab } from "@/pages/org/settings/settings"; +import { pluralOfTrialDaysRemaining } from "@/plurals/trial-days-remaining"; +import { OrgTab, RouteNamespace } from "@/routes"; +import { AnalyticsTrackEvent } from "@/trackEvents"; import type { APIPaginatedList, APISortQuery } from "@/types/api"; import { CollectionAccess, type Collection } from "@/types/collection"; import { type Metrics } from "@/types/org"; import { SortDirection } from "@/types/utils"; -import { richText } from "@/utils/rich-text"; +import { track } from "@/utils/analytics"; +import { onboardingSteps } from "@/utils/onboarding/onboardingEvents"; +import { hasUsage } from "@/utils/orgs"; +import { AppStateService } from "@/utils/state"; import { tw } from "@/utils/tailwind"; import { timeoutCache } from "@/utils/timeoutCache"; -import { toShortUrl } from "@/utils/url-helpers"; import { cached } from "@/utils/weakCache"; +import "@/pages/org/dashboard/components/dashboard-guides"; + enum CollectionGridView { All = "all", Public = "public", } -const PAGE_SIZE = 16; +const PAGE_SIZE = 12; @customElement("btrix-dashboard") @localized() export class Dashboard extends BtrixElement { - @property({ type: Boolean }) - isCrawler?: boolean; - @state() private metrics?: Metrics; @@ -56,6 +75,12 @@ export class Dashboard extends BtrixElement { @state() collectionPage = parsePage(new URLSearchParams(location.search).get("page")); + @property({ type: Boolean }) + showOnboardingFinishedDialog = false; + + @query("#onboarding-finished") + private readonly onboardingFinishedDialog?: Dialog | null; + // Used for busting cache when updating visible collection cacheBust = 0; @@ -106,64 +131,15 @@ export class Dashboard extends BtrixElement { return html` ${pageHeader({ - title: this.userOrg?.name, - secondary: html` - ${when( - this.org?.publicDescription, - (publicDescription) => html` -
- ${richText(publicDescription)} -
- `, - )} - ${when(this.org?.publicUrl, (urlStr) => { - let url: URL; - try { - url = new URL(urlStr); - } catch { - return nothing; - } - - return html` - - `; - })} - `, + title: msg("Dashboard"), actions: html` ${when( - this.appState.isAdmin, + this.appState.isCrawler, () => - html` - - `, - )} - ${when( - this.isCrawler, - () => - html` { const { value } = e.detail.item; @@ -212,312 +188,556 @@ export class Dashboard extends BtrixElement { `, )} `, - classNames: tw`border-b-transparent lg:mb-2`, + classNames: tw`mb-3`, })} -
-
- ${this.renderCard( - msg("Storage"), - (metrics) => html` - ${this.renderStorageMeter(metrics)} -
- ${this.renderStat({ - value: metrics.crawlCount, - secondaryValue: this.localize.bytes( - metrics.storageUsedCrawls, - ), - singleLabel: msg("Crawl"), - pluralLabel: msg("Crawls"), - - iconProps: { - name: "gear-wide-connected", - class: storageColorClasses.crawls, - }, - button: { - url: "/items/crawl", - }, - })} - ${this.renderStat({ - value: metrics.uploadCount, - secondaryValue: this.localize.bytes( - metrics.storageUsedUploads, - ), - singleLabel: msg("Upload"), - pluralLabel: msg("Uploads"), - - iconProps: { - name: "upload", - class: storageColorClasses.uploads, - }, - button: { - url: "/items/upload", - }, - })} - ${this.renderStat({ - value: metrics.profileCount, - secondaryValue: this.localize.bytes( - metrics.storageUsedProfiles, - ), - singleLabel: msg("Browser Profile"), - pluralLabel: msg("Browser Profiles"), - iconProps: { - name: "window-fullscreen", - class: storageColorClasses.browserProfiles, - }, - button: { - url: "/browser-profiles", - }, - })} - ${metrics.storageUsedSeedFiles || - metrics.storageUsedThumbnails || - metrics.storageUsedDedupeIndexes - ? this.renderMiscStorage(metrics) - : nothing} - - - ${this.renderStat({ - value: metrics.archivedItemCount, - singleLabel: msg("Archived Item"), - pluralLabel: msg("Archived Items"), - iconProps: { - name: "file-zip-fill", - class: storageColorClasses.archivedItems, - }, - button: { - url: "/items", - }, - })} - ${when( - metrics.storageUsedBytes && !metrics.storageQuotaBytes, - () => html` - ${this.renderStat({ - value: this.localize.bytes(metrics.storageUsedBytes, { - compactDisplay: "short", - }), - singleLabel: msg("Total"), - iconProps: { - name: "database-fill", - }, - })} - `, - )} -
- `, - )} - ${this.renderCard( - msg("Crawling"), - (metrics) => html` - ${this.renderCrawlingMeter(metrics)} -
- ${this.renderStat({ - value: - metrics.workflowsRunningCount && metrics.maxConcurrentCrawls - ? `${metrics.workflowsRunningCount} / ${metrics.maxConcurrentCrawls}` - : metrics.workflowsRunningCount, - singleLabel: msg("Crawl Running"), - pluralLabel: msg("Crawls Running"), - iconProps: { - name: "dot", - library: "app", - class: metrics.workflowsRunningCount - ? tw`animate-pulse text-green-600` - : tw`text-neutral-600`, - }, - button: { - url: "/workflows?isCrawlRunning=true", - }, - })} - ${this.renderStat({ - value: metrics.workflowsQueuedCount, - singleLabel: msg("Crawl Workflow Waiting"), - pluralLabel: msg("Crawl Workflows Waiting"), - iconProps: { - name: "hourglass-split", - class: tw`text-violet-600`, - }, - })} - - ${this.renderStat({ - value: metrics.crawlPageCount, - singleLabel: msg("Page Crawled"), - pluralLabel: msg("Pages Crawled"), - iconProps: { - name: "file-richtext-fill", - class: storageColorClasses.crawls, - }, - })} - ${this.renderStat({ - value: metrics.uploadPageCount, - singleLabel: msg("Page Uploaded"), - pluralLabel: msg("Pages Uploaded"), - iconProps: { - name: "file-richtext-fill", - class: storageColorClasses.uploads, - }, - })} - ${this.renderStat({ - value: metrics.pageCount, - singleLabel: msg("Page Total"), - pluralLabel: msg("Pages Total"), - iconProps: { name: "file-richtext-fill" }, - })} -
- `, - )} - ${this.renderCard( - msg("Collections"), - (metrics) => html` -
- ${this.renderStat({ - value: metrics.collectionsCount, - singleLabel: msg("Collection Total"), - pluralLabel: msg("Collections Total"), - iconProps: { name: "collection-fill" }, - button: { - url: "/collections", - }, - })} - ${this.renderStat({ - value: metrics.publicCollectionsCount, - singleLabel: msg("Shareable Collection"), - pluralLabel: msg("Shareable Collections"), - iconProps: { - name: "people-fill", - class: tw`text-emerald-600`, - }, - })} -
- `, +
${when(this.org, () => this.renderContent())}
+ ${this.renderOnboardingFinishedDialog()} + `; + } + + private renderOnboardingFinishedDialog() { + return html` { + void this.onboardingFinishedDialog?.hide(); + }} + > +

+ ${msg("You’re all set up!")} + ${msg("Next, you may want to check out the following guides.")} +

+
+ ${generalGuides({ + trialing: this.appState.isTrialing, + billing: this.appState.settings?.billingEnabled, + })} +
+ +

+ ${msg( + "These guides and more resources are always available at the bottom of your dashboard.", + )} +

+ + { + AppStateService.partialUpdateOnboarding({ showOnboarding: true }); + void this.onboardingFinishedDialog?.hide(); + track(AnalyticsTrackEvent.UndoFinishSetUp); + }} + > + ${msg("I’m still getting set up")} + + void this.onboardingFinishedDialog?.hide()} + > + ${msg("Go to Dashboard")} +
`; + } + + private renderContent() { + const showUsage = + this.org?.enablePublicProfile || hasUsage(this.org, this.metrics); + const showOnboarding = + this.appState.onboarding?.showOnboarding || !showUsage; + + const content: TemplateResult[] = []; + + if (showOnboarding) { + const org_name = html`${this.org?.name}`; + const primary = html`${this.appState.isTrialing + ? html`
${this.guardedRenderTrialInfo()}
` + : nothing} +
+
+

+ ${msg("Welcome to Browsertrix")} +

+

+ ${msg(html`Let’s get you set up with your new org ${org_name}.`)} +

+
+
+ ${when( + showUsage, + () => + html` { + AppStateService.partialUpdateOnboarding({ + showOnboarding: false, + }); + void this.onboardingFinishedDialog?.show(); + track(AnalyticsTrackEvent.FinishSetUp); + }} + > + + ${msg("I’m Set Up")} + `, + )} +
+
+ `; + const aside = html`${this.renderOnboardingChecklist()} ${resourcesList()} +
${this.renderGuideSearch()} ${docsFeedback(docsEmail)}
`; + + content.push(primaryWithAside(primary, aside)); + } + + if (showUsage) { + if (showOnboarding) { + content.push(html``); + } + + content.push( + html`
${this.renderStats()}
+
${this.renderCollections()}
`, + ); + } + + if (!showOnboarding) { + const primary = html``; + const aside = html`${resourcesList()} +
${this.renderGuideSearch()} ${docsFeedback(docsEmail)}
`; + + content.push(html` + + ${pageHeading({ content: msg("Guides"), classNames: tw`mb-2` })} + ${primaryWithAside(primary, aside)} + `); + } + + return html`${content}`; + } + + private renderOnboardingChecklist() { + return html`
+ ${dashboardHeading(msg("Onboarding Checklist"), { aside: true })} + +
+ ${onboardingChecklist( + onboardingSteps.map(({ key, label, description }) => ({ + key, + content: label, + tooltip: description, + })), + )} +
+
`; + } + + private renderGuideSearch() { + return html` + { + this.dispatchEvent( + new CustomEvent( + "btrix-user-guide-show", + { + detail: { path: "?q=" }, + bubbles: true, + composed: true, + }, + ), + ); + }} + > + + ${msg("Search Guides")} + + `; + } + + private guardedRenderTrialInfo() { + return guard( + [this.org?.id, this.org?.subscription?.status], + this.renderTrialInfo, + ); + } + + private readonly renderTrialInfo = () => { + if (!this.org || !this.appState.isTrialing) return; + + const { daysUntilTrialEnd, trialEndDate } = OrgStatusBanner.trialInfo( + this.org, + this.localize, + ); + const warning = daysUntilTrialEnd <= TRIAL_DAYS_LEFT_SHOW_WARNING; + + return html`
+ + ${warning + ? html` + ${msg("Trial ending soon")}` + : html` + ${pluralOfTrialDaysRemaining(daysUntilTrialEnd)}`} + +
`; + }; + + private renderStats() { + return html`
+ ${pageHeading({ content: msg("Usage Stats") })} +
+ ${this.appState.onboarding?.showOnboarding + ? nothing + : this.guardedRenderTrialInfo()} + ${when( + this.appState.settings?.billingEnabled, + () => + html` + + ${msg("Manage Plan")} + `, )}
- -
-
-
- ${pageHeading({ - content: - this.collectionsView === CollectionGridView.Public - ? msg("Public Collections") - : msg("All Collections"), +
+
+ ${this.renderCard( + msg("Storage"), + (metrics) => html` + ${this.renderStorageMeter(metrics)} +
+ ${this.renderStat({ + value: metrics.crawlCount, + secondaryValue: this.localize.bytes(metrics.storageUsedCrawls), + singleLabel: msg("Crawl"), + pluralLabel: msg("Crawls"), + + iconProps: { + name: "gear-wide-connected", + class: storageColorClasses.crawls, + }, + button: { + url: "/items/crawl", + }, })} - ${this.collectionsView === CollectionGridView.Public - ? html` — - - ${this.org?.enablePublicProfile - ? msg("Visit public collections gallery") - : msg("Preview public collections gallery")} - - - ${this.org?.enablePublicProfile - ? html`` - : nothing} - ` + ${this.renderStat({ + value: metrics.uploadCount, + secondaryValue: this.localize.bytes(metrics.storageUsedUploads), + singleLabel: msg("Upload"), + pluralLabel: msg("Uploads"), + + iconProps: { + name: "upload", + class: storageColorClasses.uploads, + }, + button: { + url: "/items/upload", + }, + })} + ${this.renderStat({ + value: metrics.profileCount, + secondaryValue: this.localize.bytes( + metrics.storageUsedProfiles, + ), + singleLabel: msg("Browser Profile"), + pluralLabel: msg("Browser Profiles"), + iconProps: { + name: "window-fullscreen", + class: storageColorClasses.browserProfiles, + }, + button: { + url: "/browser-profiles", + }, + })} + ${metrics.storageUsedSeedFiles || + metrics.storageUsedThumbnails || + metrics.storageUsedDedupeIndexes + ? this.renderMiscStorage(metrics) : nothing} -
-
+ + + ${this.renderStat({ + value: metrics.archivedItemCount, + singleLabel: msg("Archived Item"), + pluralLabel: msg("Archived Items"), + iconProps: { + name: "file-zip-fill", + class: storageColorClasses.archivedItems, + }, + button: { + url: "/items", + }, + })} ${when( - this.appState.isCrawler, + metrics.storageUsedBytes && !metrics.storageQuotaBytes, () => html` - - - + ${this.renderStat({ + value: this.localize.bytes(metrics.storageUsedBytes, { + compactDisplay: "short", + }), + singleLabel: msg("Total"), + iconProps: { + name: "database-fill", + }, + })} `, )} + + `, + )} + ${this.renderCard( + msg("Crawling"), + (metrics) => html` + ${this.renderCrawlingMeter(metrics)} +
+ ${this.renderStat({ + value: + metrics.workflowsRunningCount && metrics.maxConcurrentCrawls + ? `${metrics.workflowsRunningCount} / ${metrics.maxConcurrentCrawls}` + : metrics.workflowsRunningCount, + singleLabel: msg("Crawl Running"), + pluralLabel: msg("Crawls Running"), + iconProps: { + name: "dot", + library: "app", + class: metrics.workflowsRunningCount + ? tw`animate-pulse text-green-600` + : tw`text-neutral-600`, + }, + button: { + url: "/workflows?isCrawlRunning=true", + }, + })} + ${this.renderStat({ + value: metrics.workflowsQueuedCount, + singleLabel: msg("Crawl Workflow Waiting"), + pluralLabel: msg("Crawl Workflows Waiting"), + iconProps: { + name: "hourglass-split", + class: tw`text-violet-600`, + }, + })} + + ${this.renderStat({ + value: metrics.crawlPageCount, + singleLabel: msg("Page Crawled"), + pluralLabel: msg("Pages Crawled"), + iconProps: { + name: "file-richtext-fill", + class: storageColorClasses.crawls, + }, + })} + ${this.renderStat({ + value: metrics.uploadPageCount, + singleLabel: msg("Page Uploaded"), + pluralLabel: msg("Pages Uploaded"), + iconProps: { + name: "file-richtext-fill", + class: storageColorClasses.uploads, + }, + })} + ${this.renderStat({ + value: metrics.pageCount, + singleLabel: msg("Page Total"), + pluralLabel: msg("Pages Total"), + iconProps: { name: "file-richtext-fill" }, + })} +
+ `, + )} + ${this.renderCard( + msg("Collections"), + (metrics) => html` +
+ ${this.renderStat({ + value: metrics.collectionsCount, + singleLabel: msg("Collection Total"), + pluralLabel: msg("Collections Total"), + iconProps: { name: "collection-fill" }, + button: { + url: "/collections", + }, + })} + ${this.renderStat({ + value: metrics.publicCollectionsCount, + singleLabel: msg("Shareable Collection"), + pluralLabel: msg("Shareable Collections"), + iconProps: { + name: "people-fill", + class: tw`text-emerald-600`, + }, + })} +
+ `, + )} +
`; + } - { - this.collectionPage = 1; - this.collectionsView = (e.target as SlRadioGroup) - .value as CollectionGridView; - }} - > - - - - - - - -
- -
- { - this.collectionRefreshing = e.detail.id; - void this.collections.run([ - this.orgId, - this.collectionsView, - this.collectionPage, - ++this.cacheBust, - ]); - }} - > - ${this.renderNoPublicCollections()} - ${this.collectionsView === CollectionGridView.Public - ? msg("Your org doesn’t have any public collections.") - : msg("Your org doesn’t have any collections yet.")} - ${this.collections.value && - this.collections.value.total > this.collections.value.items.length - ? html` - { - this.collectionPage = e.detail.page; - }} - slot="pagination" + private renderCollections() { + const noCollections = + this.collections.value && !this.collections.value.items.length; + + return html`
+ ${pageHeading({ + content: + this.collectionsView === CollectionGridView.Public + ? msg("Public Collections") + : msg("All Collections"), + })} +
+ ${when( + this.collectionsView === CollectionGridView.Public, + () => + html` + + + ${when( + this.appState.isAdmin, + () => + html` - - ` - : nothing} - - ${this.collections.status === TaskStatus.PENDING && - this.collections.value - ? html`
+ `, + )}`, + )} + ${when( + this.appState.isCrawler, + () => html` + + + + `, + )} + { + this.collectionPage = 1; + this.collectionsView = (e.target as SlRadioGroup) + .value as CollectionGridView; + }} + > + + + + + + + +
+
+
+ { + this.collectionRefreshing = e.detail.id; + void this.collections.run([ + this.orgId, + this.collectionsView, + this.collectionPage, + ++this.cacheBust, + ]); + }} + > + ${this.renderNoPublicCollections()} + ${this.collectionsView === CollectionGridView.Public + ? msg("Your org doesn’t have any public collections.") + : msg("Your org doesn’t have any collections yet.")} + ${this.collections.value && + this.collections.value.total > this.collections.value.items.length + ? html` + { + this.collectionPage = e.detail.page; + }} + slot="pagination" > - -
` - : nothing} -
- -
- `; + + ` + : nothing} + + ${this.collections.status === TaskStatus.PENDING && + this.collections.value + ? html`
+ +
` + : nothing} + `; } private renderMiscStorage(metrics: Metrics) { diff --git a/frontend/src/pages/org/dashboard/components/dashboard-guide-card.ts b/frontend/src/pages/org/dashboard/components/dashboard-guide-card.ts new file mode 100644 index 0000000000..693c4afb79 --- /dev/null +++ b/frontend/src/pages/org/dashboard/components/dashboard-guide-card.ts @@ -0,0 +1,138 @@ +import { consume } from "@lit/context"; +import { localized, msg } from "@lit/localize"; +import type { SlIcon } from "@shoelace-style/shoelace"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { TailwindElement } from "@/classes/TailwindElement"; +import { docsUrlContext, type DocsUrlContext } from "@/context/docs-url"; +import { type BtrixUserGuideShowEvent } from "@/events/btrix-user-guide-show"; +import { tw } from "@/utils/tailwind"; + +/** + * @slot title + * @slot label + * @cssPart base + * @cssPart label + * @cssPart icon + * @cssPart icon-background + */ +@customElement("btrix-dashboard-guide-card") +@localized() +export class DashboardGuideCard extends TailwindElement { + @consume({ context: docsUrlContext }) + private readonly docsUrl?: DocsUrlContext; + + @property({ type: String }) + variant: "button" | "card" = "card"; + + @property({ type: String }) + icon?: SlIcon["name"]; + + @property({ type: String }) + path?: string; + + render() { + const link = `${this.docsUrl}user-guide/${this.path}`; + + if (this.variant === "button") { + return html` +
+ ${this.renderIcon()} + + ${msg("View Guide")} + +
+
+ ${this.renderTitle()} ${this.renderBody()} +
+
`; + } + + return html`
+ ${this.renderIcon()} +
+
+ ${this.renderTitle()} ${this.renderBody()} +
+ + + ${msg("View Guide")} + + + + + + +
+
`; + } + + private renderIcon() { + if (!this.icon) return; + + return html`
+ +
`; + } + + private renderTitle() { + return html`

+ +

`; + } + + private renderBody() { + return html`

+ +

`; + } + + private readonly onClick = (e: MouseEvent) => { + if (e.metaKey) return; + + e.preventDefault(); + + this.dispatchEvent( + new CustomEvent( + "btrix-user-guide-show", + { + detail: { path: this.path }, + bubbles: true, + composed: true, + }, + ), + ); + }; +} diff --git a/frontend/src/pages/org/dashboard/components/dashboard-guides.ts b/frontend/src/pages/org/dashboard/components/dashboard-guides.ts new file mode 100644 index 0000000000..5fe63a7004 --- /dev/null +++ b/frontend/src/pages/org/dashboard/components/dashboard-guides.ts @@ -0,0 +1,309 @@ +import { consume } from "@lit/context"; +import { localized, msg } from "@lit/localize"; +import { html } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +import { dashboardHeading } from "../layouts/dashboardHeading"; +import { generalGuides } from "../templates/generalGuides"; + +import { BtrixElement } from "@/classes/BtrixElement"; +import { docsUrlContext, type DocsUrlContext } from "@/context/docs-url"; +import { type BtrixUserGuideShowEvent } from "@/events/btrix-user-guide-show"; +import { AnalyticsTrackEvent } from "@/trackEvents"; +import { track, type AnalyticsTrackProps } from "@/utils/analytics"; +import { hasUsage } from "@/utils/orgs"; +import { tw } from "@/utils/tailwind"; + +import "./dashboard-guide-card"; + +@customElement("btrix-dashboard-guides") +@localized() +export class DashboardGuides extends BtrixElement { + @consume({ context: docsUrlContext }) + private readonly docsUrl?: DocsUrlContext; + + @property({ type: Boolean }) + onboarding = false; + + render() { + return html` +
+ ${this.onboarding + ? this.renderGettingStarted() + : html`
+ ${this.renderGeneralGuides()} +
`} +
+ +
+ ${this.onboarding + ? html`
+ ${dashboardHeading(msg("Quick Start"))} +

+ ${msg("What would you like to archive?")} +

+
` + : dashboardHeading(msg("Crawling"))} + ${this.renderCrawlingGuides()} +
+ + +
+ + ${msg("More Guides")} +
+ ${this.renderSettingsGuides()} +
+ `; + } + + private renderGettingStarted() { + return html`
+
+
+ ${dashboardHeading(msg("Getting Started"), { + leading: false, + })} +

+ ${msg( + "Whether you’re new to web archiving or Browsertrix, we have guides to help you get started.", + )} +

+
+
+
+ ${this.renderGeneralGuides({ + classes: tw`@4xl/org:rounded-none @4xl/org:border-0`, + })} +
+
`; + } + + private renderGeneralGuides({ classes }: { classes?: string } = {}) { + return generalGuides({ + onboarding: this.onboarding, + trialing: this.appState.isTrialing, + billing: this.appState.settings?.billingEnabled, + classes, + }); + } + + private renderCrawlingGuides() { + const cardClasses = tw`col-span-full block h-full @container/card @xl/org:col-span-1`; + + const trackProps = { + trialing: this.appState.isTrialing, + has_usage: hasUsage(this.org), + } satisfies AnalyticsTrackProps; + + return html` +
+ + track(AnalyticsTrackEvent.OpenedCrawlingOnePageGuide, trackProps)} + > + ${msg("One Page")} + ${msg("Archive a single page on a website")} + + + track( + AnalyticsTrackEvent.OpenedCrawlingSocialMediaGuide, + trackProps, + )} + > + ${msg("Social Media Page")} + ${msg("Archive a social media post or profile")} + + + track(AnalyticsTrackEvent.OpenedCrawlingWebsiteGuide, trackProps)} + > + ${msg("Entire Website")} + ${msg("Archive every page on a website")} + +
+ `; + } + + private renderSettingsGuides() { + const sectionClasses = tw`col-span-full flex flex-col @2xl/org:col-span-1`; + const headingClasses = tw`mb-3 text-base font-medium leading-6 @4xl/org:text-sm`; + const listClasses = tw`flex-1 [&>li:not(:last-of-type)]:mb-3.5`; + + return html`
+
+

${msg("Crawl Settings")}

+
    +
  • + ${this.renderGuideLink({ + label: msg("Exclude pages from the crawl"), + icon: "file-earmark-minus", + path: "workflow-setup/#exclude-pages", + })} +
  • +
  • + ${this.renderGuideLink({ + label: msg("Include extra pages"), + icon: "file-earmark-plus", + path: "workflow-setup/#additional-scope", + })} +
  • +
  • + ${this.renderGuideLink({ + label: msg("Watch pages in real-time"), + icon: "eye-fill", + path: "running-crawl/#watch-crawl", + })} +
  • +
  • + ${this.renderGuideLink({ + label: msg("Avoid crawler traps in real-time"), + icon: "shield-exclamation", + path: "running-crawl/#live-exclusion-editing", + })} +
  • +
  • + ${this.renderGuideLink({ + label: msg("Review, rate, and comment on crawled content"), + icon: "chat-square-text", + path: "quality-assurance", + })} +
  • +
+
+
+

${msg("Org Settings")}

+
    +
  • + ${this.renderGuideLink({ + label: msg("Manage billing"), + icon: "credit-card-fill", + path: "org-settings/#billing", + })} +
  • +
  • + ${this.renderGuideLink({ + label: msg("Invite team members"), + icon: "people-fill", + path: "org-members", + })} +
  • +
  • + ${this.renderGuideLink({ + label: msg("Import a collection"), + icon: "box-arrow-in-down", + path: "archived-items/#uploading-web-archives", + })} +
  • +
  • + ${this.renderGuideLink({ + label: msg("Create a public archive"), + icon: "globe", + path: "public-collections-gallery", + })} +
  • +
  • + ${this.renderGuideLink({ + label: msg("Enable deduplication"), + icon: "stack", + path: "deduplication/#enable-deduplication-across-your-entire-organization", + })} +
  • +
+
+
+

${msg("Account Settings")}

+
    +
  • + ${this.renderGuideLink({ + label: msg("Change your email"), + icon: "person-badge", + path: "user-settings", + })} +
  • +
  • + ${this.renderGuideLink({ + label: msg("Change your password"), + icon: "shield-lock-fill", + path: "change-password/#change-password", + })} +
  • +
  • + ${this.renderGuideLink({ + label: msg("Choose your preferred language"), + icon: "translate", + path: "user-settings/#language", + })} +
  • +
  • + ${this.renderGuideLink({ + label: msg("Change how your name appears to team members"), + icon: "file-person-fill", + path: "user-settings/#display-name", + })} +
  • +
+
+
`; + } + + private readonly renderGuideLink = ({ + label, + icon, + path, + }: { + label: string; + icon: string; + path: string; + }) => { + const link = `${this.docsUrl}user-guide/${path}`; + + return html``; + }; +} diff --git a/frontend/src/pages/org/dashboard/layouts/dashboardHeading.ts b/frontend/src/pages/org/dashboard/layouts/dashboardHeading.ts new file mode 100644 index 0000000000..425cbb14f1 --- /dev/null +++ b/frontend/src/pages/org/dashboard/layouts/dashboardHeading.ts @@ -0,0 +1,26 @@ +import clsx from "clsx"; +import { html } from "lit"; + +import { tw } from "@/utils/tailwind"; + +export function dashboardHeading( + content: string, + { + aside, + leading = true, + classes, + }: { aside?: boolean; leading?: boolean; classes?: string } = {}, +) { + return html`
+

+ ${content} +

+
`; +} diff --git a/frontend/src/pages/org/dashboard/layouts/primaryWithAside.ts b/frontend/src/pages/org/dashboard/layouts/primaryWithAside.ts new file mode 100644 index 0000000000..7a4e17faea --- /dev/null +++ b/frontend/src/pages/org/dashboard/layouts/primaryWithAside.ts @@ -0,0 +1,19 @@ +import { html, type TemplateResult } from "lit"; + +import { tw } from "@/utils/tailwind"; + +export function primaryWithAside( + primary: TemplateResult, + aside: TemplateResult, +) { + return html`
+
${primary}
+
+ +
+
`; +} diff --git a/frontend/src/pages/org/dashboard/templates/docsFeedback.ts b/frontend/src/pages/org/dashboard/templates/docsFeedback.ts new file mode 100644 index 0000000000..1d35c90ac1 --- /dev/null +++ b/frontend/src/pages/org/dashboard/templates/docsFeedback.ts @@ -0,0 +1,25 @@ +import { msg } from "@lit/localize"; +import { html } from "lit"; + +export function docsFeedback(email: string) { + return html`
+

+ ${msg("Can’t find a specific guide?")} +

+

+ ${msg("Let us know what you’re looking for and how we can help.")} +

+
+ + + ${msg("Contact Us")} +
+
`; +} diff --git a/frontend/src/pages/org/dashboard/templates/generalGuides.ts b/frontend/src/pages/org/dashboard/templates/generalGuides.ts new file mode 100644 index 0000000000..64221e8c0a --- /dev/null +++ b/frontend/src/pages/org/dashboard/templates/generalGuides.ts @@ -0,0 +1,114 @@ +import { msg } from "@lit/localize"; +import clsx from "clsx"; +import { html, type TemplateResult } from "lit"; + +import { tw } from "@/utils/tailwind"; + +export function generalGuides({ + onboarding, + trialing, + billing, + classes, +}: { + onboarding?: boolean; + trialing?: boolean; + billing?: boolean; + classes?: string; +} = {}) { + const cards: TemplateResult[] = []; + + if (onboarding) { + cards.push( + html` + ${msg("Introduction to Concepts")} + ${msg(html`An overview of concepts & terms used in Browsertrix`)} + + + ${msg("Navigating Your Org")} + ${msg("Where to find org features and reference guides")} + `, + ); + + if (trialing) { + cards.push( + html` + ${msg("Your Free Trial")} + ${msg("How to get the most out of your trial experience")} + `, + ); + } else { + cards.push( + html` + ${msg("Org Settings")} + ${billing + ? msg("Manage your plan, invite team members, and more") + : msg("Change your org name, invite team members, and more")} + `, + ); + } + } else { + cards.push( + html` + ${msg("Org Dashboard")} + ${msg("Read about the features of your dashboard")} + + + ${msg("Crawl Workflows")} + ${msg("Learn how to schedule, run, and manage crawls")} + + + ${msg("Browser Profiles")} + ${msg("Learn how to address crawl impediments like login prompts")} + + + ${msg("Collections")} + ${msg( + "Learn how to combine, de-duplicate, and share crawled content", + )} + `, + ); + } + + return html` +
+ ${cards} +
+ `; +} diff --git a/frontend/src/pages/org/dashboard/templates/onboardingChecklist.ts b/frontend/src/pages/org/dashboard/templates/onboardingChecklist.ts new file mode 100644 index 0000000000..8f00152380 --- /dev/null +++ b/frontend/src/pages/org/dashboard/templates/onboardingChecklist.ts @@ -0,0 +1,52 @@ +import { html, type TemplateResult } from "lit"; + +import { type OnboardingStep } from "@/utils/onboarding/onboardingEvents"; +import appState from "@/utils/state"; + +export function onboardingChecklist( + items: { + key: OnboardingStep; + content: TemplateResult | string; + tooltip: TemplateResult | string; + }[], +) { + return html`
    + ${items.map((item, i) => { + const checked = appState.onboarding?.stepsComplete?.[item.key]; + + return html` +
  1. + ${i === 0 + ? html`
    +
    +
    +
    +
    +
    +
    ` + : i === items.length - 1 + ? html`
    +
    +
    +
    +
    +
    +
    ` + : html`
    `} + +
    ${item.tooltip}
    + +
    +
  2. + `; + })} +
`; +} diff --git a/frontend/src/pages/org/dashboard/templates/resourcesList.ts b/frontend/src/pages/org/dashboard/templates/resourcesList.ts new file mode 100644 index 0000000000..d104e23056 --- /dev/null +++ b/frontend/src/pages/org/dashboard/templates/resourcesList.ts @@ -0,0 +1,82 @@ +import { msg } from "@lit/localize"; +import { html, type TemplateResult } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { dashboardHeading } from "../layouts/dashboardHeading"; + +import { tw } from "@/utils/tailwind"; + +function externalLink({ + label, + href, + icon, + rel, +}: { + label: string | TemplateResult; + href: string; + icon: string; + rel?: string; +}) { + return html` + + ${label} + + `; +} + +export function resourcesList() { + return html`
+ ${dashboardHeading(msg("Resources"), { aside: true })} + +
    +
  • + ${externalLink({ + label: msg("Community Forum"), + icon: "menu-up", + href: "https://forum.webrecorder.net/", + })} +
  • +
  • + ${externalLink({ + label: msg("Product Announcements"), + icon: "megaphone", + href: "https://webrecorder.net/blog/product/", + })} +
  • +
  • + ${externalLink({ + label: msg("Feature Roadmap"), + icon: "kanban", + href: "https://github.com/orgs/webrecorder/projects/9/views/20", + rel: "noopener noreferrer nofollow", + })} +
  • +
  • + ${externalLink({ + label: msg(html`Webinars & Presentations`), + icon: "youtube", + href: "https://www.youtube.com/@webrecorder", + rel: "noopener noreferrer nofollow", + })} +
  • +
  • + ${externalLink({ + label: msg("More Resources"), + icon: "journal-bookmark", + href: "https://webrecorder.net/resources/", + })} +
  • +
+
`; +} diff --git a/frontend/src/pages/org/index.ts b/frontend/src/pages/org/index.ts index ffff84dd49..31246588ed 100644 --- a/frontend/src/pages/org/index.ts +++ b/frontend/src/pages/org/index.ts @@ -1,6 +1,7 @@ import { provide } from "@lit/context"; import { localized, msg, str } from "@lit/localize"; import { Task } from "@lit/task"; +import clsx from "clsx"; import { html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { choose } from "lit/directives/choose.js"; @@ -39,6 +40,10 @@ import type { QuotaUpdateDetail } from "@/controllers/api"; import needLogin from "@/decorators/needLogin"; import type { BtrixRequestOrgUpdate } from "@/events/btrix-request-org-update"; import type { SelectJobTypeEvent } from "@/features/crawl-workflows/new-workflow-dialog"; +import { + OrgStatusBanner, + OrgStatusName, +} from "@/features/org/org-status-banner"; import { CommonTab, OrgTab, RouteNamespace, WorkflowTab } from "@/routes"; import type { CrawlerChannelsAPIResponse, @@ -51,6 +56,7 @@ import type { DuplicateWorkflowSettings } from "@/utils/crawl-workflows/settings import { DEFAULT_MAX_SCALE, getDefaultProxyId } from "@/utils/crawler"; import { type OrgData } from "@/utils/orgs"; import { AppStateService } from "@/utils/state"; +import { tw } from "@/utils/tailwind"; import type { FormState as WorkflowFormState } from "@/utils/workflow"; import "./crawling"; @@ -146,6 +152,9 @@ export class Org extends BtrixElement { @state() private isCreateDialogVisible = false; + @state() + private showOrgStatusBanner = false; + private readonly [searchOrgContextKey] = new SearchOrgContextController(this); private readonly [orgUploadsContextKey] = new OrgUploadsContextController( this, @@ -257,6 +266,22 @@ export class Org extends BtrixElement { this.openDialog(dialogName); } } + + if ( + (changedProperties.has("orgTab") || + changedProperties.has("appState.org")) && + this.org + ) { + // Hide banner if on dashboard and actively trialing + if (this.orgTab === OrgTab.Dashboard) { + const alerts = OrgStatusBanner.alerts(this.org); + const trialStatus = alerts.find(({ test }) => test()); + + this.showOrgStatusBanner = trialStatus?.name !== OrgStatusName.Trialing; + } else { + this.showOrgStatusBanner = true; + } + } } private readonly onRequestUpdateOrg = (e: BtrixRequestOrgUpdate) => { @@ -344,12 +369,20 @@ export class Org extends BtrixElement { >
- + ${when( + this.showOrgStatusBanner, + () => html``, + )} ${this.renderOrgNavBar()}
${when(this.userOrg, (userOrg) => @@ -549,8 +582,6 @@ export class Org extends BtrixElement { private readonly renderDashboard = () => { return html` `; diff --git a/frontend/src/pages/org/settings/components/billing.ts b/frontend/src/pages/org/settings/components/billing.ts index 1aad5a02fb..0c2e0a1477 100644 --- a/frontend/src/pages/org/settings/components/billing.ts +++ b/frontend/src/pages/org/settings/components/billing.ts @@ -17,6 +17,10 @@ import { columns } from "@/layouts/columns"; import { SubscriptionStatus, type BillingPortal } from "@/types/billing"; import type { Metrics, OrgData, OrgQuotas } from "@/types/org"; import { humanizeExecutionSeconds } from "@/utils/executionTimeFormatter"; +import { + completeOnboardingStep, + OnboardingStep, +} from "@/utils/onboarding/onboardingEvents"; import { pluralOf } from "@/utils/pluralize"; import { tw } from "@/utils/tailwind"; @@ -73,6 +77,8 @@ export class OrgSettingsBilling extends BtrixElement { try { const { portalUrl } = await this.getPortalUrl(); + completeOnboardingStep(OnboardingStep.ReviewPlan); + if (portalUrl) { return portalUrl; } else { diff --git a/frontend/src/pages/org/settings/settings.ts b/frontend/src/pages/org/settings/settings.ts index 21ef538f10..b6478fc51f 100644 --- a/frontend/src/pages/org/settings/settings.ts +++ b/frontend/src/pages/org/settings/settings.ts @@ -31,7 +31,7 @@ import "./components/deduplication"; const styles = unsafeCSS(stylesheet); -type Tab = +export type Tab = | "information" | "members" | "billing" diff --git a/frontend/src/pages/public/org.ts b/frontend/src/pages/public/org.ts index 59cca3d66a..74648c386e 100644 --- a/frontend/src/pages/public/org.ts +++ b/frontend/src/pages/public/org.ts @@ -47,7 +47,7 @@ export class PublicOrg extends BtrixElement { } return html` -
+
${this.orgCollections.render({ complete: (profile) => profile diff --git a/frontend/src/plurals/trial-days-remaining.ts b/frontend/src/plurals/trial-days-remaining.ts new file mode 100644 index 0000000000..59dc1f2899 --- /dev/null +++ b/frontend/src/plurals/trial-days-remaining.ts @@ -0,0 +1,34 @@ +import { msg, str } from "@lit/localize"; + +import localize from "@/utils/localize"; +import { pluralize } from "@/utils/pluralize"; + +export const pluralOfTrialDaysRemaining = (number: number) => { + const count = localize.number(number); + return pluralize(number, { + zero: msg("0 days left of trial", { + desc: "plural form of 'X days left of trial' for zero trial days remaining", + id: "trial_days_remaining.plural.zero", + }), + one: msg("1 day left of trial", { + desc: "plural form of 'X days left of trial' for one active crawl", + id: "trial_days_remaining.plural.one", + }), + two: msg("2 days left of trial", { + desc: "plural form of 'X days left of trial' for two trial days remaining", + id: "trial_days_remaining.plural.two", + }), + few: msg(str`${count} days left of trial`, { + desc: "plural form of 'X days left of trial' for few trial days remaining", + id: "trial_days_remaining.plural.few", + }), + many: msg(str`${count} days left of trial`, { + desc: "plural form of 'X days left of trial' for many trial days remaining", + id: "trial_days_remaining.plural.many", + }), + other: msg(str`${count} days left of trial`, { + desc: "plural form of 'X days left of trial' for other trial days remaining", + id: "trial_days_remaining.plural.other", + }), + }); +}; diff --git a/frontend/src/stories/decorators/orgDecorator.ts b/frontend/src/stories/decorators/orgDecorator.ts index b9dbd908cf..0d2d7b350a 100644 --- a/frontend/src/stories/decorators/orgDecorator.ts +++ b/frontend/src/stories/decorators/orgDecorator.ts @@ -1,58 +1,128 @@ import type { StoryContext, StoryFn } from "@storybook/web-components"; -import { html, LitElement } from "lit"; +import { addDays } from "date-fns/fp"; +import { html } from "lit"; import { customElement, property } from "lit/decorators.js"; import mapValues from "lodash/fp/mapValues"; import orgMock from "@/__mocks__/api/orgs/[id]"; +import { TailwindElement } from "@/classes/TailwindElement"; +import { SubscriptionStatus } from "@/types/billing"; +import { type Onboarding } from "@/types/onboarding"; +import { type OrgData } from "@/types/org"; import { AppStateService } from "@/utils/state"; -const { users, usage, quotas, ...org } = orgMock; +const { + users, + usage, + quotas, + bytesStored, + bytesStoredCrawls, + bytesStoredUploads, + bytesStoredProfiles, + ...org +} = orgMock; -export { orgMock }; +export const subscription = { + subId: "storybook-subscription-id", + planId: "starter", + status: SubscriptionStatus.Active, + futureCancelDate: addDays(7)(new Date()).toISOString(), + readOnlyOnCancel: false, +}; + +export { orgMock, subscription as orgSubscriptionMock }; export type StorybookOrgProps = { - orgUsers?: boolean; - orgUsage?: boolean; - orgQuotas?: boolean; + orgUsers?: boolean | OrgData["users"]; + orgUsage?: boolean | OrgData["usage"]; + orgQuotas?: boolean | OrgData["quotas"]; + orgSubscription?: boolean | OrgData["subscription"]; + orgOnboarding?: Onboarding; }; @customElement("btrix-storybook-org") -export class StorybookOrg extends LitElement { - @property({ type: Boolean }) - users?: boolean; +export class StorybookOrg extends TailwindElement { + @property({ type: Object }) + users?: OrgData["users"]; + + @property({ type: Object }) + usage?: OrgData["usage"]; - @property({ type: Boolean }) - usage?: boolean; + @property({ type: Object }) + quotas?: OrgData["quotas"]; - @property({ type: Boolean }) - quotas?: boolean; + @property({ type: Object }) + subscription?: OrgData["subscription"]; + + @property({ type: Object }) + onboarding?: Partial; connectedCallback(): void { super.connectedCallback(); + // TODO Allow editing settings + AppStateService.updateSettings({ + registrationEnabled: false, + jwtTokenLifetime: 0, + defaultBehaviorTimeSeconds: 0, + defaultPageLoadTimeSeconds: 0, + maxPagesPerCrawl: 0, + numBrowsersPerInstance: 0, + maxBrowserWindows: 0, + billingEnabled: true, + signUpUrl: "", + salesEmail: "", + supportEmail: "", + }); AppStateService.updateOrg({ ...org, - users: this.users ? users : {}, - usage: this.usage ? usage : {}, - quotas: this.quotas - ? quotas - : (mapValues(() => 0, quotas) as typeof quotas), + ...(this.usage + ? { + bytesStored, + bytesStoredCrawls, + bytesStoredUploads, + bytesStoredProfiles, + } + : { + bytesStored: 0, + bytesStoredCrawls: 0, + bytesStoredUploads: 0, + bytesStoredProfiles: 0, + }), + users: this.users || {}, + usage: this.usage || {}, + subscription: this.subscription || null, + quotas: this.quotas || (mapValues(() => 0, quotas) as typeof quotas), + note: "", }); + + if (this.onboarding) { + AppStateService.partialUpdateOnboarding(this.onboarding); + } } render() { - return html``; + return html`
+ +
`; } } export function orgDecorator(story: StoryFn, context: StoryContext) { const { args } = context; - const { orgUsers, orgUsage, orgQuotas } = args as StorybookOrgProps; + const { orgUsers, orgUsage, orgQuotas, orgSubscription, orgOnboarding } = + args as StorybookOrgProps; return html` ${story(args, context)} `; diff --git a/frontend/src/stories/decorators/userDecorator.ts b/frontend/src/stories/decorators/userDecorator.ts index 956bfb1055..0829679065 100644 --- a/frontend/src/stories/decorators/userDecorator.ts +++ b/frontend/src/stories/decorators/userDecorator.ts @@ -3,20 +3,21 @@ import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators.js"; import userMock from "@/__mocks__/api/users/me"; +import { AccessCode } from "@/types/org"; import { AppStateService } from "@/utils/state"; import { formatAPIUser } from "@/utils/user"; export { userMock }; export type StorybookUserProps = { - user?: boolean; + user?: boolean | "admin" | "crawler"; auth?: boolean; }; @customElement("btrix-storybook-user") export class StorybookOrg extends LitElement { - @property({ type: Boolean }) - user?: boolean; + @property({ type: String }) + user?: "admin" | "crawler" | "viewer"; @property({ type: Boolean }) auth?: boolean; @@ -33,7 +34,20 @@ export class StorybookOrg extends LitElement { } if (this.user) { - AppStateService.updateUser(formatAPIUser(userMock)); + AppStateService.updateUser( + formatAPIUser({ + ...userMock, + orgs: userMock.orgs.map((org) => ({ + ...org, + role: + this.user === "admin" + ? AccessCode.owner + : this.user === "crawler" + ? AccessCode.crawler + : AccessCode.viewer, + })), + }), + ); } } @@ -46,7 +60,10 @@ export function userDecorator(story: StoryFn, context: StoryContext) { const { args } = context; const { user, auth } = args as StorybookUserProps; - return html` + return html` ${story(args, context)} `; } diff --git a/frontend/src/stories/pages/dashboard/Dashboard.stories.ts b/frontend/src/stories/pages/dashboard/Dashboard.stories.ts new file mode 100644 index 0000000000..4a443c7ea8 --- /dev/null +++ b/frontend/src/stories/pages/dashboard/Dashboard.stories.ts @@ -0,0 +1,329 @@ +import type { Meta, StoryObj } from "@storybook/web-components"; +import { addDays } from "date-fns/fp"; +import { delay, http, HttpResponse } from "msw"; +import { type DecoratorFunction } from "storybook/internal/types"; + +import { renderComponent, type RenderProps } from "./Dashboard"; +import { + collections, + metrics, + metricsWithStorageQuota, + metricsWithUsage, + quotas, + quotasWithExecutionMinutes, + subscription, +} from "./data"; + +import collectionsMock from "@/__mocks__/api/collections"; +import { TRIAL_DAYS_LEFT_SHOW_WARNING } from "@/features/org/org-status-banner"; +import { + orgDecorator, + orgMock, + type StorybookOrgProps, +} from "@/stories/decorators/orgDecorator"; +import { + userDecorator, + type StorybookUserProps, +} from "@/stories/decorators/userDecorator"; +import { type APIPaginatedList } from "@/types/api"; +import { SubscriptionStatus } from "@/types/billing"; +import { type Collection } from "@/types/collection"; +import { type Metrics } from "@/types/org"; + +const meta = { + title: "Pages/Dashboard", + component: "btrix-dashboard", + tags: ["autodocs"], + decorators: [ + userDecorator as DecoratorFunction, + orgDecorator as DecoratorFunction, + ], + render: renderComponent, + argTypes: {}, + args: { + user: "crawler", + auth: true, + orgUsers: true, + }, + loaders: [ + // Clear onboarding info from local storage + () => window.localStorage.clear(), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const collectionsRequest = () => + http.get(/\/collections/, async ({ request }) => { + const url = new URL(request.url); + + await delay(500); + + const collectionItems = + url.searchParams.get("access") === "public" + ? collectionsMock.items.filter(({ access }) => access === "public") + : collectionsMock.items; + const pageSize = + url.searchParams.get("pageSize") ?? collectionsMock.pageSize; + + return HttpResponse.json>({ + total: collectionItems.length, + page: 1, + pageSize, + items: collectionItems.slice(0, +pageSize), + } as APIPaginatedList); + }); +const emptyCollectionsRequest = () => + http.get(/\/collections/, async () => { + await delay(500); + return HttpResponse.json>(collections); + }); + +export const WithoutUsage: Story = { + args: { + orgQuotas: { + ...quotas, + ...quotasWithExecutionMinutes, + }, + }, + parameters: { + msw: { + handlers: [ + http.get(/\/metrics/, async () => { + await delay(500); + return HttpResponse.json({ + ...metrics, + ...metricsWithStorageQuota, + }); + }), + emptyCollectionsRequest(), + ], + }, + }, +}; + +export const WithoutCollections: Story = { + args: { + orgQuotas: { + ...quotas, + ...quotasWithExecutionMinutes, + }, + orgUsage: true, + }, + parameters: { + msw: { + handlers: [ + http.get(/\/metrics/, async () => { + await delay(500); + return HttpResponse.json({ + ...metrics, + ...metricsWithStorageQuota, + ...metricsWithUsage, + }); + }), + emptyCollectionsRequest(), + ], + }, + }, +}; + +export const WithCollections: Story = { + args: { + orgQuotas: { + ...quotas, + ...quotasWithExecutionMinutes, + }, + orgUsage: true, + }, + parameters: { + msw: { + handlers: [ + http.get(/\/metrics/, async () => { + await delay(500); + return HttpResponse.json({ + ...metrics, + ...metricsWithStorageQuota, + ...metricsWithUsage, + }); + }), + collectionsRequest(), + ], + }, + }, +}; + +export const OnboardingWithUsage: Story = { + args: { + orgQuotas: { + ...quotas, + ...quotasWithExecutionMinutes, + }, + orgUsage: true, + orgOnboarding: { + orgId: orgMock.id, + showOnboarding: true, + }, + }, + parameters: { + msw: { + handlers: [ + http.get(/\/metrics/, async () => { + await delay(500); + return HttpResponse.json({ + ...metrics, + ...metricsWithStorageQuota, + ...metricsWithUsage, + }); + }), + collectionsRequest(), + ], + }, + }, +}; + +export const OnboardingFinished: Story = { + args: { + orgQuotas: { + ...quotas, + ...quotasWithExecutionMinutes, + }, + orgUsage: true, + showOnboardingFinishedDialog: true, + }, + parameters: { + msw: { + handlers: [ + http.get(/\/metrics/, async () => { + await delay(500); + return HttpResponse.json({ + ...metrics, + ...metricsWithStorageQuota, + ...metricsWithUsage, + }); + }), + collectionsRequest(), + ], + }, + }, +}; + +export const TrialWithoutUsage: Story = { + args: { + orgQuotas: { + ...quotas, + ...quotasWithExecutionMinutes, + }, + orgSubscription: { + ...subscription, + status: SubscriptionStatus.Trialing, + }, + }, + parameters: { + msw: { + handlers: [ + http.get(/\/metrics/, async () => { + await delay(500); + return HttpResponse.json({ + ...metrics, + ...metricsWithStorageQuota, + }); + }), + emptyCollectionsRequest(), + ], + }, + }, +}; + +export const TrialWithUsage: Story = { + args: { + orgQuotas: { + ...quotas, + ...quotasWithExecutionMinutes, + }, + orgUsage: true, + orgSubscription: { + ...subscription, + status: SubscriptionStatus.Trialing, + }, + }, + parameters: { + msw: { + handlers: [ + http.get(/\/metrics/, async () => { + await delay(500); + return HttpResponse.json({ + ...metrics, + ...metricsWithStorageQuota, + ...metricsWithUsage, + }); + }), + emptyCollectionsRequest(), + ], + }, + }, +}; + +export const TrialEndingWithoutUsage: Story = { + args: { + orgQuotas: { + ...quotas, + ...quotasWithExecutionMinutes, + }, + orgSubscription: { + ...subscription, + status: SubscriptionStatus.Trialing, + futureCancelDate: addDays( + TRIAL_DAYS_LEFT_SHOW_WARNING, + new Date(), + ).toISOString(), + }, + }, + parameters: { + msw: { + handlers: [ + http.get(/\/metrics/, async () => { + await delay(500); + return HttpResponse.json({ + ...metrics, + ...metricsWithStorageQuota, + }); + }), + emptyCollectionsRequest(), + ], + }, + }, +}; + +export const TrialEndingWithUsage: Story = { + args: { + orgQuotas: { + ...quotas, + ...quotasWithExecutionMinutes, + }, + orgUsage: true, + orgSubscription: { + ...subscription, + status: SubscriptionStatus.Trialing, + futureCancelDate: addDays( + TRIAL_DAYS_LEFT_SHOW_WARNING, + new Date(), + ).toISOString(), + }, + }, + parameters: { + msw: { + handlers: [ + http.get(/\/metrics/, async () => { + await delay(500); + return HttpResponse.json({ + ...metrics, + ...metricsWithStorageQuota, + ...metricsWithUsage, + }); + }), + emptyCollectionsRequest(), + ], + }, + }, +}; diff --git a/frontend/src/stories/pages/dashboard/Dashboard.ts b/frontend/src/stories/pages/dashboard/Dashboard.ts new file mode 100644 index 0000000000..d7e6d6abfe --- /dev/null +++ b/frontend/src/stories/pages/dashboard/Dashboard.ts @@ -0,0 +1,17 @@ +import { html } from "lit"; + +import "@/features/collections"; +import "@/features/meters"; +import "@/pages/org/dashboard"; + +import type { Dashboard } from "@/pages/org/dashboard"; +import { type StorybookOrgProps } from "@/stories/decorators/orgDecorator"; +import { type StorybookUserProps } from "@/stories/decorators/userDecorator"; + +export type RenderProps = Dashboard & StorybookUserProps & StorybookOrgProps; + +export const renderComponent = (props: Partial) => { + return html``; +}; diff --git a/frontend/src/stories/pages/dashboard/data.ts b/frontend/src/stories/pages/dashboard/data.ts new file mode 100644 index 0000000000..f1751fedbc --- /dev/null +++ b/frontend/src/stories/pages/dashboard/data.ts @@ -0,0 +1,67 @@ +import { + orgMock, + orgSubscriptionMock, +} from "@/stories/decorators/orgDecorator"; +import { type APIPaginatedList } from "@/types/api"; +import { type Collection } from "@/types/collection"; +import { type Metrics, type OrgData } from "@/types/org"; + +export const metrics = { + storageUsedBytes: 0, + storageUsedCrawls: 0, + storageUsedUploads: 0, + storageUsedProfiles: 0, + storageUsedSeedFiles: 0, + storageUsedThumbnails: 0, + storageUsedDedupeIndexes: 0, + storageQuotaBytes: 0, + archivedItemCount: 0, + crawlCount: 0, + uploadCount: 0, + pageCount: 0, + crawlPageCount: 0, + uploadPageCount: 0, + profileCount: 0, + workflowsRunningCount: 0, + maxConcurrentCrawls: 0, + workflowsQueuedCount: 0, + collectionsCount: 0, + publicCollectionsCount: 0, +} satisfies Metrics; + +export const metricsWithUsage = { + storageUsedBytes: orgMock.bytesStored, + storageUsedCrawls: orgMock.bytesStoredCrawls, + storageUsedUploads: orgMock.bytesStoredUploads, + storageUsedProfiles: orgMock.bytesStoredProfiles, + archivedItemCount: 40, + crawlCount: 20, + uploadCount: 2, + pageCount: 1070, + crawlPageCount: 1050, + uploadPageCount: 20, + profileCount: 2, +} satisfies Partial; + +export const metricsWithStorageQuota = { + storageQuotaBytes: orgMock.quotas.storageQuota, +} satisfies Partial; + +export const subscription = { + ...orgSubscriptionMock, +} satisfies OrgData["subscription"]; + +export const quotas = { + ...orgMock.quotas, +} satisfies OrgData["quotas"]; + +export const quotasWithExecutionMinutes = { + maxExecMinutesPerMonth: 180, +} satisfies Partial; + +export const collections = { + total: 0, + page: 1, + pageSize: 1000, + items: [], +} satisfies APIPaginatedList; diff --git a/frontend/src/theme.stylesheet.css b/frontend/src/theme.stylesheet.css index fa5a394c68..5ad161ee98 100644 --- a/frontend/src/theme.stylesheet.css +++ b/frontend/src/theme.stylesheet.css @@ -35,10 +35,22 @@ /* Replay */ --btrix-rwp-height: calc(100vh - 4rem); + /* Layout */ + --btrix-page-width: 82.5rem; /* 14 4.5rem columns with 1.5rem gutter (1320px) */ + --btrix-user-guide-width: 31rem; + --btrix-full-without-user-guide-width: calc( + 100vw - var(--btrix-user-guide-width) + ); + --btrix-3xl-with-user-guide-margin: calc( + calc( + var(--btrix-full-without-user-guide-width) - var(--btrix-page-width) + ) / + 2 + ); + /* * Shoelace Theme Tokens - */ - /* Primary */ + */ /* Primary */ --sl-color-primary-50: theme(colors.primary.50); --sl-color-primary-100: theme(colors.primary.100); --sl-color-primary-200: theme(colors.primary.200); diff --git a/frontend/src/trackEvents.ts b/frontend/src/trackEvents.ts index 83cf2ab65e..3ffbec2d4b 100644 --- a/frontend/src/trackEvents.ts +++ b/frontend/src/trackEvents.ts @@ -17,16 +17,16 @@ export enum AnalyticsTrackEvent { */ ExpandWorkflowFormSection = "Expand workflow form section", /** - * Trial + * Onboarding */ - TrialOpenedUserGuide = "[Trial] Opened user guide", - TrialCreatedWorkflow = "[Trial] Created workflow", - TrialReplayedCrawledItem = "[Trial] Replayed crawled item", - TrialVisitedBillingTab = "[Trial] Visited billing tab", + CompleteOnboardingStep = "Completed onboarding step", + FinishSetUp = "Finished set up", + UndoFinishSetUp = "Undo finished set up", /** * User Guide */ OpenedCrawlingOnePageGuide = "Opened crawling one page guide", OpenedCrawlingSocialMediaGuide = "Opened crawling social media guide", OpenedCrawlingWebsiteGuide = "Opened crawling website guide", + ClickSearchGuides = "Clicked search guides", } diff --git a/frontend/src/types/onboarding.ts b/frontend/src/types/onboarding.ts new file mode 100644 index 0000000000..0819c1af08 --- /dev/null +++ b/frontend/src/types/onboarding.ts @@ -0,0 +1,7 @@ +import { type OnboardingStep } from "@/utils/onboarding/onboardingEvents"; + +export type Onboarding = { + orgId: string; + showOnboarding?: boolean; + stepsComplete?: Partial>; +}; diff --git a/frontend/src/utils/analytics.ts b/frontend/src/utils/analytics.ts index 98e7c272c5..e358d71033 100644 --- a/frontend/src/utils/analytics.ts +++ b/frontend/src/utils/analytics.ts @@ -11,8 +11,10 @@ export type AnalyticsTrackProps = { org_slug?: string | null; logged_in?: boolean; trialing?: boolean; + has_usage?: boolean; collection_slug?: string; section?: string; + completed_onboarding_step?: string; }; declare global { diff --git a/frontend/src/utils/onboarding/onboardingEvents.ts b/frontend/src/utils/onboarding/onboardingEvents.ts new file mode 100644 index 0000000000..4c76ac176d --- /dev/null +++ b/frontend/src/utils/onboarding/onboardingEvents.ts @@ -0,0 +1,57 @@ +import { msg } from "@lit/localize"; +import { html } from "lit"; + +import { AnalyticsTrackEvent } from "@/trackEvents"; +import { track } from "@/utils/analytics"; +import appState, { AppStateService } from "@/utils/state"; + +export enum OnboardingStep { + OpenUserGuide = "Open User Guide", + CreateWorkflow = "Create Workflow", + ReplayItem = "Replay Item", + ReviewPlan = "Review Plan", +} + +export const onboardingSteps = [ + { + key: OnboardingStep.OpenUserGuide, + label: msg("Locate the user guide"), + description: msg( + "To open the user guide without leaving the Browsertrix application, select the “User Guide” button at the top of each page.", + ), + }, + { + key: OnboardingStep.CreateWorkflow, + label: msg("Create a crawl workflow"), + description: msg( + "Open the “Create New...” dropdown menu at the top of your dashboard and choose “Crawl Workflow”.", + ), + }, + { + key: OnboardingStep.ReplayItem, + label: msg("Replay crawled item"), + description: msg( + "Navigate to “Archived Items”, select the crawled item, and go to “Replay” to view an interactive replay of crawled pages.", + ), + }, + { + key: OnboardingStep.ReviewPlan, + label: msg("Review your plan"), + description: msg( + html`Navigate to “Settings” and review “Billing & Usage” to confirm that + the current plan is right for you.`, + ), + }, +] as const; + +export function completeOnboardingStep(step: OnboardingStep) { + if (appState.onboarding?.stepsComplete?.[step]) return; + + AppStateService.partialUpdateOnboarding({ + stepsComplete: { [step]: true }, + }); + + track(AnalyticsTrackEvent.CompleteOnboardingStep, { + completed_onboarding_step: step, + }); +} diff --git a/frontend/src/utils/orgs.ts b/frontend/src/utils/orgs.ts index c5ede6e153..6cb642ed45 100644 --- a/frontend/src/utils/orgs.ts +++ b/frontend/src/utils/orgs.ts @@ -1,4 +1,5 @@ -import { AccessCode, type OrgData } from "@/types/org"; +import { SubscriptionStatus } from "@/types/billing"; +import { AccessCode, type Metrics, type OrgData } from "@/types/org"; export * from "@/types/org"; @@ -26,8 +27,23 @@ export function isArchivingDisabled( ): boolean { return Boolean( !org || - org.readOnly || - org.storageQuotaReached || - (checkExecMinutesQuota ? org.execMinutesQuotaReached : false), + org.readOnly || + org.storageQuotaReached || + (checkExecMinutesQuota ? org.execMinutesQuotaReached : false), ); } + +export function isTrialing(org?: OrgData | null) { + return org?.subscription?.status === SubscriptionStatus.Trialing; +} + +export function hasUsage(org?: OrgData | null, metrics?: Metrics) { + return org + ? Boolean( + org.bytesStored || + metrics?.workflowsQueuedCount || + metrics?.workflowsRunningCount || + metrics?.collectionsCount, + ) + : undefined; +} diff --git a/frontend/src/utils/state.ts b/frontend/src/utils/state.ts index a113b851eb..7d44ac002c 100644 --- a/frontend/src/utils/state.ts +++ b/frontend/src/utils/state.ts @@ -1,6 +1,7 @@ /** * Store and access application-wide state */ +import { differenceInDays } from "date-fns/fp"; import { mergeDeep } from "immutable"; import { locked, options, transaction, use } from "lit-shared-state"; @@ -8,6 +9,7 @@ import { persist } from "./persist"; import { authSchema, type Auth } from "@/types/auth"; import type { FeatureFlags } from "@/types/featureFlags"; +import { type Onboarding } from "@/types/onboarding"; import type { OrgData } from "@/types/org"; import { userInfoSchema, @@ -17,10 +19,12 @@ import { type UserPreferences, } from "@/types/user"; import type { AppSettings } from "@/utils/app"; -import { isAdmin, isCrawler } from "@/utils/orgs"; +import { hasUsage, isAdmin, isCrawler, isTrialing } from "@/utils/orgs"; export { use }; +const MAX_DAYS_ONBOARDING = 30; + export function makeAppStateService() { // Prevent state updates from any component const { state, unlock } = locked(); @@ -36,6 +40,9 @@ export function makeAppStateService() { @options(persist(window.localStorage)) userPreferences: UserPreferences | null = null; + @options(persist(window.localStorage)) + onboarding: Onboarding | null = null; + // TODO persist here auth: Auth | null = null; @@ -48,6 +55,7 @@ export function makeAppStateService() { // Org details org: OrgData | null | undefined = undefined; + @options(persist(window.sessionStorage)) userGuideOpen = false; // Since org slug is used to ID an org, use `userOrg` @@ -84,6 +92,14 @@ export function makeAppStateService() { return false; } + get isTrialing() { + return isTrialing(this.org); + } + + get hasUsage() { + return hasUsage(this.org); + } + readonly featureFlags = { has: (flag: FeatureFlags) => this.org?.featureFlags[flag] ?? false, excludes: (flag: FeatureFlags) => !this.featureFlags.has(flag), @@ -144,17 +160,40 @@ export function makeAppStateService() { } } + @transaction() + @unlock() + partialUpdateOnboarding(onboarding: Partial) { + if (onboarding) { + if (appState.onboarding) { + appState.onboarding = mergeDeep(appState.onboarding, onboarding); + } else { + appState.onboarding = { + ...onboarding, + orgId: onboarding.orgId || "", + }; + } + } else { + appState.onboarding = onboarding; + } + } + @transaction() @unlock() updateOrgSlug(orgSlug: AppState["orgSlug"]) { appState.orgSlug = orgSlug; } + @transaction() @unlock() updateOrg(org: AppState["org"]) { appState.org = org; + + if (org) { + this.updateOnboardingForOrg(org); + } } + @transaction() @unlock() partialUpdateOrg(org: { id: string } & Partial) { if (org.id && appState.org?.id === org.id) { @@ -162,6 +201,8 @@ export function makeAppStateService() { ...appState.org, ...org, }; + + this.updateOnboardingForOrg(appState.org); } else { console.warn("no matching org in app state"); } @@ -189,9 +230,34 @@ export function makeAppStateService() { appState.auth = null; appState.userInfo = null; appState.userPreferences = null; + appState.onboarding = null; appState.orgSlug = null; appState.org = undefined; } + + private updateOnboardingForOrg(org: OrgData) { + const trialing = isTrialing(org); + const showOnboarding = + trialing || + !hasUsage(org) || + (org.created + ? differenceInDays(new Date(org.created))(new Date()) <= + MAX_DAYS_ONBOARDING + : false); + + if (appState.onboarding?.orgId === org.id) { + appState.onboarding = { + ...appState.onboarding, + showOnboarding: appState.onboarding.showOnboarding ?? showOnboarding, + }; + } else { + // Reset onboarding + appState.onboarding = { + orgId: org.id, + showOnboarding: showOnboarding, + }; + } + } } return new AppStateActions(); diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 934f663a17..c316ca97f3 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -112,9 +112,9 @@ function makeTheme() { 14: "repeat(14, minmax(0, 1fr))", }, screens: { - desktop: "82.5rem", // 14 4.5rem columns with 1.5rem gutter - // Override default of: - // => @media (min-width: 1024px) { ... } + desktop: "var(--btrix-page-width)", + // This should match --btrix-page-width + --btrix-user-guide-width + "3xl": "1816px", }, transitionDuration: { "x-slow": "var(--sl-transition-x-slow)", diff --git a/frontend/web-test-runner.config.mjs b/frontend/web-test-runner.config.mjs index c16c12a9c8..ba03a5fc8f 100644 --- a/frontend/web-test-runner.config.mjs +++ b/frontend/web-test-runner.config.mjs @@ -73,7 +73,6 @@ export default { "node_modules/slugify/**/*", "node_modules/parse-ms/**/*", "node_modules/regex-colorize/**/*", - "node_modules/@floating-ui/**/*", ], }), importMapsPlugin({ @@ -81,6 +80,10 @@ export default { importMap: { imports: { ...emptyImports, + // Prevents component dependencies from impacting page tests + "./src/components": fileURLToPath( + new URL("./src/__mocks__/_empty.js", import.meta.url), + ), "./src/shoelace": fileURLToPath( new URL("./src/__mocks__/shoelace.js", import.meta.url), ),