feat(3000): help & support panel (#20710)
* html for new support pane
* add algolia search component
* polish
* docs are not old anymore
* ux improvements
* add filters
* reset active option
* styles
* email an engineer button
* fix panel title
* add checkmark to resolved questions
* add result count to tags / allow tabbing through tags
* polish
* docs links
* tooltips
* inline support form
* remove unnecessary effect
* use correct color in tooltip
* Revert "tooltips"
This reverts commit 5603a65080
.
* hook it up
* fix lockfile
* use iconinfo
* Update UI snapshots for `chromium` (2)
* Update UI snapshots for `chromium` (1)
* Update UI snapshots for `chromium` (2)
* Update UI snapshots for `chromium` (2)
* reup lockfile
* Update UI snapshots for `chromium` (1)
* fix
* use std colors (except for purple)
* Update UI snapshots for `chromium` (1)
* Update UI snapshots for `chromium` (2)
* Update UI snapshots for `chromium` (1)
* Update UI snapshots for `chromium` (1)
* Update unit.json
* Update docker-compose.dev-full.yml
* Update docker-compose.dev-full.yml
* upgrade @babel/runtime
* move to a regular dep?
* address pr feedback
* add stories
* handle arrowLeft and ArrowRight keydown
* Update UI snapshots for `chromium` (1)
---------
Co-authored-by: Eli Kinsey <eli@ekinsey.dev>
Co-authored-by: Raquel Smith <raquelmsmith@users.noreply.github.com>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
@ -103,7 +103,6 @@ services:
|
||||
KAFKA_HOSTS: 'kafka:9092'
|
||||
REDIS_URL: 'redis://redis:6379/'
|
||||
|
||||
|
||||
plugins:
|
||||
command: ./bin/plugin-server --no-restart-loop
|
||||
restart: on-failure
|
||||
@ -152,8 +151,6 @@ services:
|
||||
volumes:
|
||||
- /var/lib/elasticsearch/data
|
||||
temporal:
|
||||
|
||||
|
||||
environment:
|
||||
- DB=postgresql
|
||||
- DB_PORT=5432
|
||||
@ -190,4 +187,3 @@ services:
|
||||
restart: on-failure
|
||||
environment:
|
||||
TEMPORAL_HOST: temporal
|
||||
|
||||
|
@ -182,4 +182,4 @@ services:
|
||||
- clickhouse
|
||||
- kafka
|
||||
- object_storage
|
||||
- temporal
|
||||
- temporal
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 996 B After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1013 B After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.4 KiB |
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.2 KiB |
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 112 KiB |
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
After Width: | Height: | Size: 99 KiB |
After Width: | Height: | Size: 100 KiB |
After Width: | Height: | Size: 188 KiB |
After Width: | Height: | Size: 180 KiB |
268
frontend/src/layout/navigation-3000/components/AlgoliaSearch.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
import { IconCheckCircle } from '@posthog/icons'
|
||||
import { LemonButton, LemonInput, LemonTag } from '@posthog/lemon-ui'
|
||||
import algoliasearch from 'algoliasearch/lite'
|
||||
import { useActions } from 'kea'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { InstantSearch, useHits, useRefinementList, useSearchBox } from 'react-instantsearch'
|
||||
import { AutoSizer } from 'react-virtualized/dist/es/AutoSizer'
|
||||
import { List } from 'react-virtualized/dist/es/List'
|
||||
|
||||
import { sidePanelStateLogic } from '~/layout/navigation-3000/sidepanel/sidePanelStateLogic'
|
||||
import { SidePanelTab } from '~/types'
|
||||
|
||||
const searchClient = algoliasearch('7VNQB5W0TX', '37f41fd37095bc85af76ed4edc85eb5a')
|
||||
|
||||
const rowRenderer = ({ key, index, style, hits, activeOption }: any): JSX.Element => {
|
||||
const { slug, title, type, resolved } = hits[index]
|
||||
return (
|
||||
// eslint-disable-next-line react/forbid-dom-props
|
||||
<li key={key} style={style} role="listitem" tabIndex={-1} className="p-1 border-b last:border-b-0">
|
||||
<LemonButton
|
||||
active={activeOption === index}
|
||||
to={`https://posthog.com/${slug}`}
|
||||
className="[&_>span>span]:flex-col [&_>span>span]:items-start [&_>span>span]:space-y-1"
|
||||
>
|
||||
<span>
|
||||
<span className="flex space-x-2 items-center">
|
||||
<p className="m-0 font-bold font-sans line-clamp-1">{title}</p>
|
||||
{type === 'question' && resolved && (
|
||||
<IconCheckCircle className="text-success size-4 flex-shrink-0" />
|
||||
)}
|
||||
</span>
|
||||
<p className="text-xs m-0 opacity-80 font-normal font-sans line-clamp-1">/{slug}</p>
|
||||
</span>
|
||||
</LemonButton>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const Hits = ({ activeOption }: { activeOption?: number }): JSX.Element => {
|
||||
const { hits } = useHits()
|
||||
return (
|
||||
<ol role="listbox" className="list-none m-0 p-0 h-[80vh]">
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => (
|
||||
<List
|
||||
scrollToIndex={activeOption}
|
||||
width={width}
|
||||
height={height}
|
||||
rowCount={hits.length}
|
||||
rowHeight={50}
|
||||
rowRenderer={(options: any) => rowRenderer({ ...options, hits, activeOption })}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
const SearchInput = ({
|
||||
value,
|
||||
setValue,
|
||||
}: {
|
||||
value: string
|
||||
setValue: React.Dispatch<React.SetStateAction<string>>
|
||||
}): JSX.Element => {
|
||||
const { refine } = useSearchBox()
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
setValue(value)
|
||||
refine(value)
|
||||
}
|
||||
|
||||
return <LemonInput onChange={handleChange} value={value} type="search" fullWidth placeholder="Search..." />
|
||||
}
|
||||
|
||||
type Tag = {
|
||||
type: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const tags: Tag[] = [
|
||||
{
|
||||
type: 'all',
|
||||
label: 'All',
|
||||
},
|
||||
{
|
||||
type: 'docs',
|
||||
label: 'Docs',
|
||||
},
|
||||
{
|
||||
type: 'question',
|
||||
label: 'Questions',
|
||||
},
|
||||
{
|
||||
type: 'tutorial',
|
||||
label: 'Tutorials',
|
||||
},
|
||||
]
|
||||
|
||||
type SearchTagProps = Tag & {
|
||||
active?: boolean
|
||||
onClick: (type: string) => void
|
||||
}
|
||||
|
||||
const SearchTag = ({ type, label, active, onClick }: SearchTagProps): JSX.Element => {
|
||||
const { refine, items } = useRefinementList({ attribute: 'type' })
|
||||
const itemCount = type !== 'all' && items.find(({ value }) => value === type)?.count
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
|
||||
e.stopPropagation()
|
||||
onClick(type)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refine(type)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<button className="p-0 cursor-pointer bg-bg-light" onClick={handleClick}>
|
||||
<LemonTag size="medium" type={active ? 'primary' : 'option'}>
|
||||
<span>{label}</span>
|
||||
{type !== 'all' && <span>({itemCount ?? 0})</span>}
|
||||
</LemonTag>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const Tags = ({
|
||||
activeTag,
|
||||
setActiveTag,
|
||||
}: {
|
||||
activeTag: string
|
||||
setActiveTag: React.Dispatch<React.SetStateAction<string>>
|
||||
}): JSX.Element => {
|
||||
const handleClick = (type: string): void => {
|
||||
setActiveTag(type)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="list-none m-0 p-0 flex space-x-1 mt-1 mb-0.5 pb-1.5 border-b px-2">
|
||||
{tags.map((tag) => {
|
||||
const { type } = tag
|
||||
return (
|
||||
<li key={type}>
|
||||
<SearchTag {...tag} active={activeTag === type} onClick={handleClick} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
const Search = (): JSX.Element => {
|
||||
const { openSidePanel } = useActions(sidePanelStateLogic)
|
||||
const { hits } = useHits()
|
||||
const { items, refine } = useRefinementList({ attribute: 'type' })
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [searchValue, setSearchValue] = useState<string>('')
|
||||
const [activeOption, setActiveOption] = useState<undefined | number>()
|
||||
const [activeTag, setActiveTag] = useState('all')
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
switch (e.key) {
|
||||
case 'Enter': {
|
||||
if (activeOption !== undefined) {
|
||||
openSidePanel(SidePanelTab.Docs, `https://posthog.com/${hits[activeOption].slug}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'Escape': {
|
||||
setSearchOpen(false)
|
||||
break
|
||||
}
|
||||
case 'ArrowDown': {
|
||||
e.preventDefault()
|
||||
setActiveOption((currOption) => {
|
||||
if (currOption === undefined || currOption >= hits.length - 1) {
|
||||
return 0
|
||||
}
|
||||
return currOption + 1
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
e.preventDefault()
|
||||
setActiveOption((currOption) => {
|
||||
if (currOption !== undefined) {
|
||||
return currOption <= 0 ? hits.length - 1 : currOption - 1
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'Tab':
|
||||
case 'ArrowRight': {
|
||||
e.preventDefault()
|
||||
const currTagIndex = tags.findIndex(({ type }) => type === activeTag)
|
||||
setActiveTag(tags[currTagIndex >= tags.length - 1 ? 0 : currTagIndex + 1].type)
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
e.preventDefault()
|
||||
const currTagIndex = tags.findIndex(({ type }) => type === activeTag)
|
||||
setActiveTag(tags[currTagIndex <= 0 ? tags.length - 1 : currTagIndex - 1].type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setSearchOpen(!!searchValue)
|
||||
setActiveOption(0)
|
||||
}, [searchValue])
|
||||
|
||||
useEffect(() => {
|
||||
setActiveOption(0)
|
||||
if (activeTag === 'all') {
|
||||
const filteredItems = items.filter(({ value }) => tags.some(({ type }) => type === value))
|
||||
filteredItems.forEach(({ value, isRefined }) => {
|
||||
if (!isRefined) {
|
||||
refine(value)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
items.forEach(({ value, isRefined }) => {
|
||||
if (isRefined) {
|
||||
refine(value)
|
||||
}
|
||||
})
|
||||
refine(activeTag)
|
||||
}
|
||||
}, [activeTag])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: any): void => {
|
||||
if (!ref?.current?.contains(e.target)) {
|
||||
setSearchOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('click', handleClick)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', handleClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref} onKeyDown={handleKeyDown}>
|
||||
<SearchInput value={searchValue} setValue={setSearchValue} />
|
||||
{searchOpen && (
|
||||
<div className="absolute w-full bg-bg-light z-50 border rounded-lg shadow-xl mt-0.5">
|
||||
<Tags activeTag={activeTag} setActiveTag={setActiveTag} />
|
||||
<Hits activeOption={activeOption} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AlgoliaSearch(): JSX.Element {
|
||||
return (
|
||||
<InstantSearch searchClient={searchClient} indexName="prod_posthog_com">
|
||||
<Search />
|
||||
</InstantSearch>
|
||||
)
|
||||
}
|
@ -1,11 +1,13 @@
|
||||
import { Meta, StoryFn } from '@storybook/react'
|
||||
import { useActions } from 'kea'
|
||||
import { router } from 'kea-router'
|
||||
import { supportLogic } from 'lib/components/Support/supportLogic'
|
||||
import { useEffect } from 'react'
|
||||
import { App } from 'scenes/App'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { mswDecorator } from '~/mocks/browser'
|
||||
import { mswDecorator, useStorybookMocks } from '~/mocks/browser'
|
||||
import organizationCurrent from '~/mocks/fixtures/api/organizations/@current/@current.json'
|
||||
import { SidePanelTab } from '~/types'
|
||||
|
||||
import { sidePanelStateLogic } from './sidePanelStateLogic'
|
||||
@ -59,3 +61,36 @@ export const SidePanelActivation: StoryFn = () => {
|
||||
export const SidePanelNotebooks: StoryFn = () => {
|
||||
return <BaseTemplate panel={SidePanelTab.Notebooks} />
|
||||
}
|
||||
|
||||
export const SidePanelSupportNoEmail: StoryFn = () => {
|
||||
return <BaseTemplate panel={SidePanelTab.Support} />
|
||||
}
|
||||
|
||||
export const SidePanelSupportWithEmail: StoryFn = () => {
|
||||
const { openEmailForm } = useActions(supportLogic)
|
||||
useStorybookMocks({
|
||||
get: {
|
||||
// TODO: setting available featues should be a decorator to make this easy
|
||||
'/api/users/@me': () => [
|
||||
200,
|
||||
{
|
||||
email: 'test@posthog.com',
|
||||
first_name: 'Test Hedgehog',
|
||||
organization: {
|
||||
...organizationCurrent,
|
||||
available_product_features: [
|
||||
{
|
||||
key: 'email_support',
|
||||
name: 'Email support',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
useEffect(() => {
|
||||
openEmailForm()
|
||||
}, [])
|
||||
return <BaseTemplate panel={SidePanelTab.Support} />
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ export const SIDE_PANEL_TABS: Record<
|
||||
noModalSupport: true,
|
||||
},
|
||||
[SidePanelTab.Support]: {
|
||||
label: 'Support',
|
||||
label: 'Help',
|
||||
Icon: IconSupport,
|
||||
Content: SidePanelSupport,
|
||||
},
|
||||
|
@ -1,51 +1,305 @@
|
||||
import { LemonButton } from '@posthog/lemon-ui'
|
||||
import {
|
||||
IconBug,
|
||||
IconChevronDown,
|
||||
IconFeatures,
|
||||
IconFlask,
|
||||
IconHelmet,
|
||||
IconMap,
|
||||
IconMessage,
|
||||
IconRewindPlay,
|
||||
IconStack,
|
||||
IconToggle,
|
||||
IconTrends,
|
||||
} from '@posthog/icons'
|
||||
import { LemonButton, Link } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { SupportForm } from 'lib/components/Support/SupportForm'
|
||||
import { supportLogic } from 'lib/components/Support/supportLogic'
|
||||
import React from 'react'
|
||||
import { billingLogic } from 'scenes/billing/billingLogic'
|
||||
import { urls } from 'scenes/urls'
|
||||
import { userLogic } from 'scenes/userLogic'
|
||||
|
||||
import { SidePanelTab } from '~/types'
|
||||
import { AvailableFeature, ProductKey, SidePanelTab } from '~/types'
|
||||
|
||||
import AlgoliaSearch from '../../components/AlgoliaSearch'
|
||||
import { SidePanelPaneHeader } from '../components/SidePanelPaneHeader'
|
||||
import { SIDE_PANEL_TABS } from '../SidePanel'
|
||||
import { sidePanelStateLogic } from '../sidePanelStateLogic'
|
||||
|
||||
const PRODUCTS = [
|
||||
{
|
||||
name: 'Product OS',
|
||||
slug: 'product-os',
|
||||
icon: <IconStack className="text-danger h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: 'Product analytics',
|
||||
slug: 'product-analytics',
|
||||
icon: <IconTrends className="text-brand-blue h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: 'Session replay',
|
||||
slug: 'session-replay',
|
||||
icon: <IconRewindPlay className="text-warning h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: 'Feature flags',
|
||||
slug: 'feature-flags',
|
||||
icon: <IconToggle className="text-success h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: 'A/B testing',
|
||||
slug: 'ab-testing',
|
||||
icon: <IconFlask className="text-purple h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
name: 'Surveys',
|
||||
slug: 'surveys',
|
||||
icon: <IconMessage className="text-danger h-5 w-5" />,
|
||||
},
|
||||
]
|
||||
|
||||
const Section = ({ title, children }: { title: string; children: React.ReactNode }): React.ReactElement => {
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<h3>{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
const SupportFormBlock = ({ onCancel }: { onCancel: () => void }): JSX.Element => {
|
||||
const { billing } = useValues(billingLogic)
|
||||
const supportResponseTimes = {
|
||||
[AvailableFeature.EMAIL_SUPPORT]: '2-3 days',
|
||||
[AvailableFeature.PRIORITY_SUPPORT]: '4-6 hours',
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Email an engineer">
|
||||
<div className="grid grid-cols-2 border rounded [&_>*]:px-2 [&_>*]:py-0.5 mb-4 bg-bg-light">
|
||||
<div className="col-span-full flex justify-between border-b bg-bg-white py-1">
|
||||
<div>
|
||||
<strong>Avg support response times</strong>
|
||||
</div>
|
||||
<div>
|
||||
<Link to={urls.organizationBilling([ProductKey.PLATFORM_AND_SUPPORT])}>Explore options</Link>
|
||||
</div>
|
||||
</div>
|
||||
{billing?.products
|
||||
?.find((product) => product.type == ProductKey.PLATFORM_AND_SUPPORT)
|
||||
?.plans?.map((plan, i) => (
|
||||
<React.Fragment key={`support-panel-${plan.plan_key}`}>
|
||||
<div className={plan.current_plan ? 'font-bold' : undefined}>
|
||||
{i == 1 ? 'Pay-per-use' : plan.name}
|
||||
{plan.current_plan && (
|
||||
<>
|
||||
{' '}
|
||||
<span className="font-normal opacity-60 text-sm">(your plan)</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={plan.current_plan ? 'font-bold' : undefined}>
|
||||
{plan.features.some((f) => f.key == AvailableFeature.PRIORITY_SUPPORT)
|
||||
? supportResponseTimes[AvailableFeature.PRIORITY_SUPPORT]
|
||||
: plan.features.some((f) => f.key == AvailableFeature.EMAIL_SUPPORT)
|
||||
? supportResponseTimes[AvailableFeature.EMAIL_SUPPORT]
|
||||
: 'Community support only'}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<SupportForm />
|
||||
<LemonButton
|
||||
form="support-modal-form"
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
data-attr="submit"
|
||||
fullWidth
|
||||
center
|
||||
className="mt-4"
|
||||
>
|
||||
Submit
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
form="support-modal-form"
|
||||
type="secondary"
|
||||
onClick={onCancel}
|
||||
fullWidth
|
||||
center
|
||||
className="mt-2"
|
||||
>
|
||||
Cancel
|
||||
</LemonButton>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
export const SidePanelSupport = (): JSX.Element => {
|
||||
const { closeSidePanel } = useActions(sidePanelStateLogic)
|
||||
const { hasAvailableFeature } = useValues(userLogic)
|
||||
const { openEmailForm, closeEmailForm } = useActions(supportLogic)
|
||||
const { isEmailFormOpen } = useValues(supportLogic)
|
||||
|
||||
const theLogic = supportLogic({ onClose: () => closeSidePanel(SidePanelTab.Support) })
|
||||
const { title } = useValues(theLogic)
|
||||
const { closeSupportForm } = useActions(theLogic)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidePanelPaneHeader title={title} />
|
||||
<SidePanelPaneHeader title={isEmailFormOpen ? title : SIDE_PANEL_TABS[SidePanelTab.Support].label} />
|
||||
|
||||
<div className="overflow-y-auto" data-attr="side-panel-support-container">
|
||||
<div className="p-3 max-w-160 w-full mx-auto">
|
||||
<SupportForm />
|
||||
<Section title="Search docs & community questions">
|
||||
<AlgoliaSearch />
|
||||
</Section>
|
||||
|
||||
<footer>
|
||||
<Section title="Explore the docs">
|
||||
<ul className="border rounded divide-y bg-bg-light dark:bg-transparent font-title font-medium">
|
||||
{PRODUCTS.map((product, index) => (
|
||||
<li key={index}>
|
||||
<Link
|
||||
to={`https://posthog.com/docs/${product.slug}`}
|
||||
className="group flex items-center justify-between px-2 py-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{product.icon}
|
||||
<span className="text-default opacity-75 group-hover:opacity-100">
|
||||
{product.name}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<IconChevronDown className="text-default h-6 w-6 opacity-60 -rotate-90 group-hover:opacity-90" />
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section title="Ask the community">
|
||||
<p>
|
||||
Questions about features, how to's, or use cases? There are thousands of discussions in our
|
||||
community forums.
|
||||
</p>
|
||||
<LemonButton
|
||||
form="support-modal-form"
|
||||
htmlType="submit"
|
||||
type="primary"
|
||||
data-attr="submit"
|
||||
fullWidth
|
||||
center
|
||||
className="mt-4"
|
||||
>
|
||||
Submit
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
form="support-modal-form"
|
||||
type="secondary"
|
||||
onClick={closeSupportForm}
|
||||
fullWidth
|
||||
center
|
||||
to="https://posthog.com/questions"
|
||||
targetBlank
|
||||
className="mt-2"
|
||||
>
|
||||
Cancel
|
||||
Ask a question
|
||||
</LemonButton>
|
||||
</footer>
|
||||
</Section>
|
||||
|
||||
<Section title="Share feedback">
|
||||
<ul>
|
||||
<li>
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
status="alt"
|
||||
to="https://github.com/posthog/posthog/issues"
|
||||
icon={<IconBug />}
|
||||
targetBlank
|
||||
>
|
||||
Report a bug
|
||||
</LemonButton>
|
||||
</li>
|
||||
<li>
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
status="alt"
|
||||
to="https://posthog.com/wip"
|
||||
icon={<IconHelmet />}
|
||||
targetBlank
|
||||
>
|
||||
See what we're building
|
||||
</LemonButton>
|
||||
</li>
|
||||
<li>
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
status="alt"
|
||||
to="https://posthog.com/roadmap"
|
||||
icon={<IconMap />}
|
||||
targetBlank
|
||||
>
|
||||
Vote on our roadmap
|
||||
</LemonButton>
|
||||
</li>
|
||||
<li>
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
status="alt"
|
||||
to="https://github.com/posthog/posthog/issues"
|
||||
icon={<IconFeatures />}
|
||||
targetBlank
|
||||
>
|
||||
Request a feature
|
||||
</LemonButton>
|
||||
</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
{hasAvailableFeature(AvailableFeature.EMAIL_SUPPORT) ? (
|
||||
<Section title="More options">
|
||||
{isEmailFormOpen ? (
|
||||
<SupportFormBlock onCancel={() => closeEmailForm()} />
|
||||
) : (
|
||||
<p>
|
||||
Can't find what you need in the docs?{' '}
|
||||
<Link onClick={() => openEmailForm()}>Email an engineer</Link>
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
) : (
|
||||
<Section title="Contact support">
|
||||
<p>
|
||||
Due to our large userbase, we're unable to offer email support to organizations on the
|
||||
free plan. But we still want to help!
|
||||
</p>
|
||||
|
||||
<ol className="pl-5">
|
||||
<li>
|
||||
<strong className="block">Search our docs</strong>
|
||||
<p>
|
||||
We're constantly updating our docs and tutorials to provide the latest
|
||||
information about installing, using, and troubleshooting.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong className="block">Ask a community question</strong>
|
||||
<p>
|
||||
Many common (and niche) questions have already been resolved by users just like
|
||||
you. (Our own engineers also keep an eye on the questions as they have time!){' '}
|
||||
<Link to="https://posthog.com/question" className="block">
|
||||
Search community questions or ask your own.
|
||||
</Link>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong className="block">
|
||||
Explore <Link to="https://posthog.com/partners">PostHog partners</Link>
|
||||
</strong>
|
||||
<p>
|
||||
Third-party providers can help with installation and debugging of data issues.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong className="block">Upgrade to a paid plan</strong>
|
||||
<p>
|
||||
Our paid plans offer email support.{' '}
|
||||
<Link to={urls.organizationBilling([ProductKey.PLATFORM_AND_SUPPORT])}>
|
||||
Explore options.
|
||||
</Link>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { IconBug, IconQuestion } from '@posthog/icons'
|
||||
import { IconBug, IconInfo, IconQuestion } from '@posthog/icons'
|
||||
import {
|
||||
LemonBanner,
|
||||
LemonInput,
|
||||
@ -6,6 +6,7 @@ import {
|
||||
LemonSegmentedButtonOption,
|
||||
lemonToast,
|
||||
Link,
|
||||
Tooltip,
|
||||
} from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { Form } from 'kea-forms'
|
||||
@ -90,9 +91,12 @@ export function SupportForm(): JSX.Element | null {
|
||||
</LemonField>
|
||||
</>
|
||||
)}
|
||||
<LemonField name="kind" label="What type of message is this?">
|
||||
<LemonField name="kind" label="Message type">
|
||||
<LemonSegmentedButton fullWidth options={SUPPORT_TICKET_OPTIONS} />
|
||||
</LemonField>
|
||||
<LemonField name="target_area" label="Topic">
|
||||
<LemonSelect fullWidth options={TARGET_AREA_TO_NAME} />
|
||||
</LemonField>
|
||||
{posthog.getFeatureFlag('show-troubleshooting-docs-in-support-form') === 'test-replay-banner' &&
|
||||
sendSupportRequest.target_area === 'session_replay' && (
|
||||
<LemonBanner type="info">
|
||||
@ -127,18 +131,6 @@ export function SupportForm(): JSX.Element | null {
|
||||
</>
|
||||
</LemonBanner>
|
||||
)}
|
||||
<LemonField name="target_area" label="What area does this best relate to?">
|
||||
<LemonSelect fullWidth type="secondary" options={TARGET_AREA_TO_NAME} />
|
||||
</LemonField>
|
||||
<LemonField name="severity_level" label="What is the severity of this issue?">
|
||||
<LemonSelect
|
||||
fullWidth
|
||||
options={Object.entries(SEVERITY_LEVEL_TO_NAME).map(([key, value]) => ({
|
||||
label: value,
|
||||
value: key,
|
||||
}))}
|
||||
/>
|
||||
</LemonField>
|
||||
<LemonField
|
||||
name="message"
|
||||
label={sendSupportRequest.kind ? SUPPORT_TICKET_KIND_TO_PROMPT[sendSupportRequest.kind] : 'Content'}
|
||||
@ -163,6 +155,30 @@ export function SupportForm(): JSX.Element | null {
|
||||
</div>
|
||||
)}
|
||||
</LemonField>
|
||||
<LemonField name="severity_level">
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="LemonLabel">
|
||||
Severity level
|
||||
<Tooltip title="Severity levels help us prioritize your request.">
|
||||
<span>
|
||||
<IconInfo className="opacity-75" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Link target="_blank" to="https://posthog.com/docs/support-options#severity-levels">
|
||||
Definitions
|
||||
</Link>
|
||||
</div>
|
||||
<LemonSelect
|
||||
fullWidth
|
||||
options={Object.entries(SEVERITY_LEVEL_TO_NAME).map(([key, value]) => ({
|
||||
label: value,
|
||||
value: key,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
</LemonField>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
@ -45,7 +45,7 @@ function getSentryLink(user: UserType | null, cloudRegion: Region | null | undef
|
||||
}
|
||||
|
||||
const SUPPORT_TICKET_KIND_TO_TITLE: Record<SupportTicketKind, string> = {
|
||||
support: 'Ask a question',
|
||||
support: 'Contact support',
|
||||
feedback: 'Give feedback',
|
||||
bug: 'Report a bug',
|
||||
}
|
||||
@ -237,6 +237,8 @@ export const supportLogic = kea<supportLogicType>([
|
||||
openSupportForm: (values: Partial<SupportFormFields>) => values,
|
||||
submitZendeskTicket: (form: SupportFormFields) => form,
|
||||
updateUrlParams: true,
|
||||
openEmailForm: true,
|
||||
closeEmailForm: true,
|
||||
})),
|
||||
reducers(() => ({
|
||||
isSupportFormOpen: [
|
||||
@ -246,6 +248,13 @@ export const supportLogic = kea<supportLogicType>([
|
||||
closeSupportForm: () => false,
|
||||
},
|
||||
],
|
||||
isEmailFormOpen: [
|
||||
false,
|
||||
{
|
||||
openEmailForm: () => true,
|
||||
closeEmailForm: () => false,
|
||||
},
|
||||
],
|
||||
})),
|
||||
forms(({ actions, values }) => ({
|
||||
sendSupportRequest: {
|
||||
|
@ -43,6 +43,7 @@ import { NodeKind } from './queries/schema'
|
||||
export type Optional<T, K extends string | number | symbol> = Omit<T, K> & { [K in keyof T]?: T[K] }
|
||||
|
||||
// Keep this in sync with backend constants/features/{product_name}.yml
|
||||
|
||||
export enum AvailableFeature {
|
||||
APPS = 'apps',
|
||||
SLACK_INTEGRATION = 'slack_integration',
|
||||
@ -143,6 +144,7 @@ export enum AvailableFeature {
|
||||
PRODUCT_ANALYTICS_SQL_QUERIES = 'product_analytics_sql_queries',
|
||||
TWOFA_ENFORCEMENT = '2fa_enforcement',
|
||||
AUDIT_LOGS = 'audit_logs',
|
||||
PRIORITY_SUPPORT = 'priority_support',
|
||||
}
|
||||
|
||||
type AvailableFeatureUnion = `${AvailableFeature}`
|
||||
|
@ -66,6 +66,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^4.7.0",
|
||||
"@babel/runtime": "^7.24.0",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/modifiers": "^6.0.1",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
@ -96,6 +97,7 @@
|
||||
"@types/react-transition-group": "^4.4.5",
|
||||
"@types/react-virtualized": "^9.21.23",
|
||||
"ajv": "^8.12.0",
|
||||
"algoliasearch": "^4.22.1",
|
||||
"antd": "^4.17.1",
|
||||
"antd-dayjs-webpack-plugin": "^1.0.6",
|
||||
"autoprefixer": "^10.4.13",
|
||||
@ -154,6 +156,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-draggable": "^4.2.0",
|
||||
"react-grid-layout": "^1.3.0",
|
||||
"react-instantsearch": "^7.6.0",
|
||||
"react-intersection-observer": "^9.5.3",
|
||||
"react-markdown": "^5.0.3",
|
||||
"react-modal": "^3.15.1",
|
||||
@ -182,7 +185,6 @@
|
||||
"@babel/preset-env": "^7.22.10",
|
||||
"@babel/preset-react": "^7.22.5",
|
||||
"@babel/preset-typescript": "^7.22.5",
|
||||
"@babel/runtime": "^7.22.10",
|
||||
"@cypress/webpack-preprocessor": "^5.17.1",
|
||||
"@playwright/test": "1.41.2",
|
||||
"@sentry/types": "7.22.0",
|
||||
|
435
pnpm-lock.yaml
@ -7,6 +7,8 @@ const config = {
|
||||
// TODO: Move all colors over to Tailwind
|
||||
// Currently color utility classes are still generated with SCSS in colors.scss due to relying on our color
|
||||
// CSS vars in lots of stylesheets
|
||||
|
||||
purple: '#B62AD9',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
|