mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +03:00
feat: add docs feedback widget (#21131)
Add a docs feedback widget to allow upvoting and downvoting pages. Votes (completely anonymized, barely more than a counter) go to a database in the main supabase-com project. If the user has accepted telemetry, the votes also go to Logflare with a bit more info. Post-vote, logged-in users can leave a comment, which goes to the `platform/feedback/send` endpoint. There is a warning in the feedback modal that the feedback is not anonymous.
This commit is contained in:
1 parent
ce0ebafab0
commit
3124f3dad7
14 files changed
+427
-31
No files matched your search
@@ -0,0 +1,202 @@
|
||||
import { useSupabaseClient } from '@supabase/auth-helpers-react'
|
||||
import { Check, MessageSquareQuote, X } from 'lucide-react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
type CSSProperties,
|
||||
type MouseEventHandler,
|
||||
forwardRef,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
|
||||
import { useIsLoggedIn } from 'common'
|
||||
import { Button, cn } from 'ui'
|
||||
|
||||
import { useSendFeedbackMutation } from '~/lib/fetch/feedback'
|
||||
import { useSendTelemetryEvent } from '~/lib/telemetry'
|
||||
import { FeedbackModal, type FeedbackFields } from './FeedbackModal'
|
||||
|
||||
const FeedbackButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{ isYes: boolean; onClick: MouseEventHandler; visible: boolean }
|
||||
>(({ isYes, onClick, visible }, ref) => {
|
||||
const isLoggedIn = useIsLoggedIn()
|
||||
if (!isLoggedIn) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-0',
|
||||
'flex items-center gap-2',
|
||||
'text-xs text-foreground-lighter',
|
||||
'hover:text-foreground',
|
||||
!visible && 'opacity-0 invisible',
|
||||
'[transition-property:opacity,color]',
|
||||
'[transition-delay:700ms,0ms]'
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{isYes ? <>What went well?</> : <>How can we improve?</>}
|
||||
<MessageSquareQuote size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
)
|
||||
})
|
||||
FeedbackButton.displayName = 'FeedbackButton'
|
||||
|
||||
type Response = 'yes' | 'no'
|
||||
|
||||
enum StateType {
|
||||
Unanswered = 'unanswered',
|
||||
Followup = 'followup',
|
||||
}
|
||||
|
||||
type State = { type: StateType.Unanswered } | { type: StateType.Followup; response: Response }
|
||||
|
||||
type Action = { event: 'VOTED'; response: Response }
|
||||
|
||||
const initialState = { type: StateType.Unanswered } satisfies State
|
||||
|
||||
function reducer(state: State, action: Action) {
|
||||
switch (action.event) {
|
||||
case 'VOTED':
|
||||
if (state.type === StateType.Unanswered)
|
||||
return { type: StateType.Followup, response: action.response }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
function Feedback() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const feedbackButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const pathname = usePathname()
|
||||
const sendTelemetryEvent = useSendTelemetryEvent()
|
||||
const { mutate: sendFeedbackComment } = useSendFeedbackMutation()
|
||||
const supabase = useSupabaseClient()
|
||||
|
||||
const unanswered = state.type === 'unanswered'
|
||||
const isYes = 'response' in state && state.response === 'yes'
|
||||
const isNo = 'response' in state && state.response === 'no'
|
||||
const showYes = unanswered || isYes
|
||||
const showNo = unanswered || isNo
|
||||
|
||||
async function sendFeedbackVote(response: Response) {
|
||||
const { error } = await supabase.from('feedback').insert({ vote: response, page: pathname })
|
||||
if (error) console.error(error)
|
||||
}
|
||||
|
||||
function handleVote(response: Response) {
|
||||
sendTelemetryEvent({
|
||||
category: 'docs',
|
||||
action: 'feedback_voted',
|
||||
label: response,
|
||||
})
|
||||
sendFeedbackVote(response)
|
||||
dispatch({ event: 'VOTED', response })
|
||||
// Focus so screen reader users are aware of the new element
|
||||
setTimeout(() => {
|
||||
feedbackButtonRef.current?.focus()
|
||||
// Wait for element to show up first
|
||||
}, 700)
|
||||
}
|
||||
|
||||
function refocusButton() {
|
||||
setTimeout(() => {
|
||||
feedbackButtonRef.current?.focus()
|
||||
// Wait for modal to disappear and button to become focusable again
|
||||
}, 100)
|
||||
}
|
||||
|
||||
async function handleSubmit({ page, comment }: FeedbackFields) {
|
||||
sendFeedbackComment({ message: comment, pathname: page })
|
||||
setModalOpen(false)
|
||||
refocusButton()
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="@container px-5 mb-6" aria-labelledby="feedback-title">
|
||||
<h3 id="feedback-title" className="block font-mono text-xs uppercase text-foreground mb-4">
|
||||
Is this helpful?
|
||||
</h3>
|
||||
<div className="relative flex flex-col gap-y-4 mb-2 @[12rem]:flex-row @[12rem]:items-center">
|
||||
<div
|
||||
style={{ '--container-flex-gap': '0.5rem' } as CSSProperties}
|
||||
className={`relative flex gap-[var(--container-flex-gap)] items-center`}
|
||||
>
|
||||
<Button
|
||||
type="outline"
|
||||
rounded
|
||||
className={cn(
|
||||
'px-1',
|
||||
!isYes && 'hover:text-brand-600 hover:border-brand-500',
|
||||
isYes && 'bg-brand text-brand-200 !border-brand disabled:opacity-100',
|
||||
!showYes && 'opacity-0 invisible',
|
||||
'[transition-property:opacity,transform,color] [transition-duration:150ms,300ms,300ms]',
|
||||
'motion-reduce:[transition-duration:150ms,1ms,300ms]'
|
||||
)}
|
||||
onClick={() => handleVote('yes')}
|
||||
disabled={state.type === StateType.Followup}
|
||||
>
|
||||
<Check size={14} strokeWidth={3} />
|
||||
<span className="sr-only">Yes</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="outline"
|
||||
rounded
|
||||
className={cn(
|
||||
'px-1',
|
||||
!isNo && 'hover:text-warning-600 hover:border-warning-500',
|
||||
isNo &&
|
||||
`bg-warning text-warning-200 !border-warning -translate-x-[calc(100%+var(--container-flex-gap,0.5rem))] disabled:opacity-100`,
|
||||
!showNo && 'opacity-0 invisible',
|
||||
'[transition-property:opacity,transform,color] [transition-duration:150ms,300ms,300ms]',
|
||||
'motion-reduce:[transition-duration:150ms,1ms,300ms]'
|
||||
)}
|
||||
onClick={() => handleVote('no')}
|
||||
disabled={state.type === StateType.Followup}
|
||||
>
|
||||
<X size={14} strokeWidth={3} />
|
||||
<span className="sr-only">No</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-1',
|
||||
'text-xs',
|
||||
'opacity-0 invisible',
|
||||
'text-left',
|
||||
'-translate-x-[0.25rem] @[12rem]:-translate-x-[1.25rem]',
|
||||
'[transition-property:opacity,transform] [transition-duration:150ms,300ms]',
|
||||
'motion-reduce:[transition-duration:150ms,1ms]',
|
||||
'delay-300',
|
||||
state.type === StateType.Followup &&
|
||||
'opacity-100 visible -translate-x-0 @[12rem]:-translate-x-[1rem]'
|
||||
)}
|
||||
>
|
||||
<span className="text-foreground-light">Thanks for your feedback!</span>
|
||||
<FeedbackButton
|
||||
ref={feedbackButtonRef}
|
||||
onClick={() => setModalOpen(true)}
|
||||
isYes={isYes}
|
||||
visible={!unanswered}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FeedbackModal
|
||||
visible={modalOpen}
|
||||
page={pathname}
|
||||
onCancel={() => {
|
||||
setModalOpen(false)
|
||||
refocusButton()
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export { Feedback }
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Button, Form, Input, Modal } from 'ui'
|
||||
|
||||
export type FeedbackFields = {
|
||||
page: string
|
||||
comment: string
|
||||
}
|
||||
|
||||
type FeedbackModalProps = {
|
||||
visible: boolean
|
||||
page: string
|
||||
onCancel: () => void
|
||||
onSubmit: (values: FeedbackFields) => void
|
||||
}
|
||||
|
||||
function FeedbackModal({ visible, page, onCancel, onSubmit }: FeedbackModalProps) {
|
||||
return (
|
||||
<Modal hideFooter header="Leave a comment" visible={visible} onEscapeKeyDown={onCancel}>
|
||||
<Form
|
||||
initialValues={{ page, comment: '' }}
|
||||
validateOnBlur
|
||||
validate={(vals) => {
|
||||
const errors: Partial<FeedbackFields> = {}
|
||||
|
||||
if (!vals.comment) {
|
||||
errors.comment = 'Required'
|
||||
}
|
||||
|
||||
return errors
|
||||
}}
|
||||
onReset={onCancel}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting }: { isSubmitting: boolean }) => (
|
||||
<>
|
||||
<Modal.Content className="pt-4 pb-2">
|
||||
<Input type="hidden" id="page" name="page" value={page} />
|
||||
<Input.TextArea
|
||||
label="Comment"
|
||||
id="comment"
|
||||
name="comment"
|
||||
size="medium"
|
||||
className="mb-2"
|
||||
textAreaClassName="resize-none"
|
||||
afterLabel=" (not anonymous)"
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content className="pt-2 pb-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button htmlType="reset" type="default" onClick={onCancel} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button htmlType="submit" loading={isSubmitting} disabled={isSubmitting}>
|
||||
Submit feedback
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export { FeedbackModal }
|
||||
@@ -0,0 +1 @@
|
||||
export { Feedback } from './Feedback'
|
||||
@@ -15,7 +15,7 @@ const TopNavBar: FC = () => {
|
||||
return (
|
||||
<nav
|
||||
aria-label="top bar"
|
||||
className="h-[60px] border-b backdrop-blur backdrop-filter bg bg-opacity-75"
|
||||
className="h-[var(--desktop-header-height,60px)] border-b backdrop-blur backdrop-filter bg bg-opacity-75"
|
||||
>
|
||||
<div className="px-5 max-w-7xl mx-auto flex gap-3 justify-between items-center h-full">
|
||||
<div className="lg:hidden">
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useTheme } from 'next-themes'
|
||||
import Image from 'next/legacy/image'
|
||||
import Link from 'next/link'
|
||||
import { type CSSProperties, type PropsWithChildren, memo, useEffect } from 'react'
|
||||
|
||||
import { cn } from 'ui'
|
||||
|
||||
import Footer from '~/components/Navigation/Footer'
|
||||
import NavigationMenu, { type MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
|
||||
import TopNavBar from '~/components/Navigation/NavigationMenu/TopNavBar'
|
||||
|
||||
import { PropsWithChildren, memo, useEffect } from 'react'
|
||||
import Footer from '~/components/Navigation/Footer'
|
||||
import { menuState, useMenuMobileOpen } from '~/hooks/useMenuState'
|
||||
|
||||
const levelsData = {
|
||||
@@ -149,13 +151,13 @@ const MobileHeader = memo(function MobileHeader({ menuId }: { menuId: MenuId })
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
className={cn(
|
||||
'transition-all ease-out z-10',
|
||||
'top-0',
|
||||
mobileMenuOpen && 'absolute',
|
||||
'flex items-center h-[40px]',
|
||||
mobileMenuOpen ? 'gap-0' : 'gap-3',
|
||||
].join(' ')}
|
||||
'flex items-center h-[var(--mobile-header-height,40px)]',
|
||||
mobileMenuOpen ? 'gap-0' : 'gap-3'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className={['mr-2', mobileMenuOpen && 'mt-0.5'].join(' ')}
|
||||
@@ -257,23 +259,27 @@ const HeaderLogo = memo(function HeaderLogo() {
|
||||
)
|
||||
})
|
||||
|
||||
const Container = memo(function Container(props: PropsWithChildren) {
|
||||
const Container = memo(function Container({
|
||||
children,
|
||||
style,
|
||||
}: PropsWithChildren<{ style?: CSSProperties }>) {
|
||||
const mobileMenuOpen = useMenuMobileOpen()
|
||||
|
||||
return (
|
||||
<div
|
||||
// #docs-content-container is used by layout to scroll to top
|
||||
id="docs-content-container"
|
||||
className={[
|
||||
className={cn(
|
||||
// 'overflow-x-auto',
|
||||
'w-full transition-all ease-out',
|
||||
// 'absolute lg:relative',
|
||||
mobileMenuOpen ? 'ml-[75%] sm:ml-[50%] md:ml-[33%] overflow-hidden' : 'overflow-auto',
|
||||
// desktop override any margin styles
|
||||
'lg:ml-0',
|
||||
].join(' ')}
|
||||
'lg:ml-0'
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
<div className="flex flex-col relative">{props.children}</div>
|
||||
<div className="flex flex-col relative">{children}</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -348,7 +354,14 @@ function MainSkeleton({ children, menuId }: PropsWithChildren<{ menuId: MenuId }
|
||||
return (
|
||||
<div className="flex flex-row h-full">
|
||||
<NavContainer menuId={menuId} />
|
||||
<Container>
|
||||
<Container
|
||||
style={
|
||||
{
|
||||
'--desktop-header-height': '60px',
|
||||
'--mobile-header-height': '40px',
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={['lg:sticky top-0 z-10 overflow-hidden'].join(' ')}>
|
||||
<TopNavBar />
|
||||
</div>
|
||||
|
||||
@@ -4,16 +4,19 @@ import Head from 'next/head'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useRouter } from 'next/router'
|
||||
import { FC, useEffect, useRef, useState } from 'react'
|
||||
import { IconExternalLink } from 'ui'
|
||||
|
||||
import { IconExternalLink, cn } from 'ui'
|
||||
import { ExpandableVideo } from 'ui-patterns/ExpandableVideo'
|
||||
|
||||
import components from '~/components'
|
||||
import { highlightSelectedTocItem } from '~/components/CustomHTMLElements/CustomHTMLElements.utils'
|
||||
import { FooterHelpCalloutType } from '~/components/FooterHelpCallout'
|
||||
import GuidesTableOfContents from '~/components/GuidesTableOfContents'
|
||||
import { type MenuId } from '~/components/Navigation/NavigationMenu/NavigationMenu'
|
||||
import useHash from '~/hooks/useHash'
|
||||
import { LayoutMainContent } from '../DefaultLayout'
|
||||
import { MainSkeleton } from '../MainSkeleton'
|
||||
import { Feedback } from '~/components/Feedback'
|
||||
import { LayoutMainContent } from '~/layouts/DefaultLayout'
|
||||
import { MainSkeleton } from '~/layouts/MainSkeleton'
|
||||
|
||||
interface Props {
|
||||
meta: {
|
||||
@@ -69,7 +72,7 @@ const Layout: FC<Props> = (props) => {
|
||||
return { text, link, level }
|
||||
})
|
||||
setTocList(newHeadings)
|
||||
}, [pathname]) // Needed to recalculate the ToC when the page changes
|
||||
}, [pathname]) // needed to recalculate the toc when path changes
|
||||
|
||||
const hasTableOfContents = tocList.length > 0
|
||||
const tocVideoPreview = `http://img.youtube.com/vi/${props.meta?.tocVideo}/0.jpg`
|
||||
@@ -162,13 +165,12 @@ const Layout: FC<Props> = (props) => {
|
||||
</div>
|
||||
{!props.hideToc && hasTableOfContents && !props.meta?.hide_table_of_contents && (
|
||||
<div
|
||||
className={[
|
||||
'col-span-3',
|
||||
'border-overlay bg-background table-of-contents-height',
|
||||
'thin-scrollbar overflow-y-auto sticky hidden md:block md:col-span-3 px-2',
|
||||
'transition-all ease-out',
|
||||
'duration-100',
|
||||
].join(' ')}
|
||||
className={cn(
|
||||
'col-span-3 self-start',
|
||||
'border-overlay bg-background',
|
||||
'thin-scrollbar overflow-y-auto hidden md:block md:col-span-3 px-2',
|
||||
'sticky top-[calc(var(--mobile-header-height,40px)+2rem)] lg:top-[calc(var(--desktop-header-height,60px)+2rem)]'
|
||||
)}
|
||||
>
|
||||
<div className="border-l">
|
||||
{props.meta?.tocVideo && !!tocVideoPreview && (
|
||||
@@ -176,6 +178,7 @@ const Layout: FC<Props> = (props) => {
|
||||
<ExpandableVideo imgUrl={tocVideoPreview} videoId={props.meta.tocVideo} />
|
||||
</div>
|
||||
)}
|
||||
<Feedback key={pathname} />
|
||||
<span className="block font-mono text-xs uppercase text-foreground px-5 mb-6">
|
||||
On this page
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { type UseMutationOptions, useMutation } from '@tanstack/react-query'
|
||||
|
||||
import { type ResponseError } from '~/types/fetch'
|
||||
import { post } from './fetchWrappers'
|
||||
|
||||
type SendFeedbackVariables = {
|
||||
message: string
|
||||
pathname?: string
|
||||
}
|
||||
|
||||
export async function sendFeedback({ message, pathname }: SendFeedbackVariables) {
|
||||
const { data, error } = await post('/platform/feedback/send', {
|
||||
body: {
|
||||
message,
|
||||
category: 'Feedback',
|
||||
tags: ['docs-feedback'],
|
||||
pathname,
|
||||
},
|
||||
})
|
||||
if (error) throw Error(`Couldn't send feedback`, { cause: error })
|
||||
return data
|
||||
}
|
||||
|
||||
type SendFeedbackData = Awaited<ReturnType<typeof sendFeedback>>
|
||||
|
||||
export const useSendFeedbackMutation = (
|
||||
options: Omit<
|
||||
UseMutationOptions<SendFeedbackData, ResponseError, SendFeedbackVariables>,
|
||||
'mutationFn'
|
||||
> = {}
|
||||
) => {
|
||||
return useMutation<SendFeedbackData, ResponseError, SendFeedbackVariables>({
|
||||
...options,
|
||||
mutationFn: (vars) => sendFeedback(vars),
|
||||
onError: (error, vars, ctx) => {
|
||||
console.error(error)
|
||||
options.onError?.(error, vars, ctx)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import { useConsent } from 'ui-patterns/ConsentToast'
|
||||
|
||||
import { unauthedAllowedPost } from './fetch/fetchWrappers'
|
||||
|
||||
type TelemetryEvent = {
|
||||
action: string
|
||||
category: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
/**
|
||||
* Sends a telemetry event to Logflare for tracking by the product team.
|
||||
*
|
||||
* Checks for user consent to telemetry before sending.
|
||||
*/
|
||||
const useSendTelemetryEvent = () => {
|
||||
const { hasAcceptedConsent } = useConsent()
|
||||
const pathname = usePathname()
|
||||
|
||||
if (!hasAcceptedConsent) return noop
|
||||
|
||||
return (event: TelemetryEvent) =>
|
||||
unauthedAllowedPost('/platform/telemetry/event', {
|
||||
// @ts-ignore - endpoint will accept this just fine
|
||||
body: {
|
||||
...event,
|
||||
page_title: document?.title,
|
||||
page_location: pathname,
|
||||
},
|
||||
})
|
||||
.then(({ error }) => {
|
||||
if (error) console.error(error)
|
||||
})
|
||||
.catch((error) => console.error(error))
|
||||
}
|
||||
|
||||
export { useSendTelemetryEvent }
|
||||
@@ -31,6 +31,7 @@
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@supabase/auth-helpers-react": "^0.4.2",
|
||||
"@supabase/supabase-js": "^2.39.3",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/react-query": "^5.13.4",
|
||||
"common": "*",
|
||||
"common-tags": "^1.8.2",
|
||||
@@ -70,7 +71,6 @@
|
||||
"remark-gfm": "^3.0.1",
|
||||
"shared-data": "*",
|
||||
"ui": "*",
|
||||
"icons": "*",
|
||||
"ui-patterns": "*",
|
||||
"unist-builder": "^3.0.1",
|
||||
"unist-util-filter": "^4.0.1",
|
||||
|
||||
@@ -69,11 +69,6 @@ article h1 {
|
||||
}
|
||||
}
|
||||
|
||||
.table-of-contents-height {
|
||||
max-height: calc(100vh - 3.75rem - 2rem);
|
||||
top: calc(4rem + 60px); // padding height + navbar height
|
||||
}
|
||||
|
||||
.width-full {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
@@ -20,5 +20,6 @@ module.exports = config({
|
||||
},
|
||||
})
|
||||
},
|
||||
require('@tailwindcss/container-queries'),
|
||||
],
|
||||
})
|
||||
Generated
+9
@@ -473,6 +473,7 @@
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@supabase/auth-helpers-react": "^0.4.2",
|
||||
"@supabase/supabase-js": "^2.39.3",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/react-query": "^5.13.4",
|
||||
"common": "*",
|
||||
"common-tags": "^1.8.2",
|
||||
@@ -14615,6 +14616,14 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@tailwindcss/container-queries": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz",
|
||||
"integrity": "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==",
|
||||
"peerDependencies": {
|
||||
"tailwindcss": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/forms": {
|
||||
"version": "0.5.6",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
create type feedback_vote as enum (
|
||||
'yes',
|
||||
'no'
|
||||
);
|
||||
|
||||
create table feedback (
|
||||
id bigint primary key generated always as identity,
|
||||
date_created date not null default current_date,
|
||||
vote feedback_vote not null,
|
||||
page text not null
|
||||
);
|
||||
|
||||
alter table feedback enable row level security;
|
||||
|
||||
create policy "Anyone can insert feedback"
|
||||
on feedback
|
||||
as permissive for insert
|
||||
to public
|
||||
with check (true);
|
||||
@@ -0,0 +1,7 @@
|
||||
create schema if not exists metrics;
|
||||
|
||||
create view metrics.feedback_response_aggregate
|
||||
as select
|
||||
count(*) filter (where vote = 'yes') as yes,
|
||||
count(*) filter (where vote = 'no') as no
|
||||
from feedback;
|
||||
Reference in new issue
Block a user