├── .gitignore ├── .storybook ├── main.js ├── manager.js ├── preview-head.html └── preview.js ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── mockServiceWorker.js ├── src ├── App.tsx ├── assets │ ├── bug.svg │ ├── idea.svg │ └── thought.svg ├── components │ ├── CloseButton.tsx │ ├── Loading.stories.tsx │ ├── Loading.tsx │ ├── Widget.stories.tsx │ ├── Widget.tsx │ └── WidgetForm │ │ ├── ScreenshotButton.tsx │ │ ├── Steps │ │ ├── FeedbackContentStep.stories.tsx │ │ ├── FeedbackContentStep.tsx │ │ ├── FeedbackSuccessStep.stories.tsx │ │ ├── FeedbackSuccessStep.tsx │ │ ├── FeedbackTypeStep.stories.tsx │ │ └── FeedbackTypeStep.tsx │ │ └── index.tsx ├── global.css ├── lib │ └── api.ts ├── main.tsx └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Env 27 | .env.local 28 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "stories": [ 3 | "../src/**/*.stories.mdx", 4 | "../src/**/*.stories.@(js|jsx|ts|tsx)" 5 | ], 6 | "addons": [ 7 | "@storybook/addon-links", 8 | "@storybook/addon-essentials", 9 | "@storybook/addon-interactions" 10 | ], 11 | "framework": "@storybook/react", 12 | "core": { 13 | "builder": "@storybook/builder-vite" 14 | }, 15 | "features": { 16 | "storyStoreV7": true 17 | } 18 | } -------------------------------------------------------------------------------- /.storybook/manager.js: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/addons'; 2 | import { themes } from '@storybook/theming'; 3 | 4 | addons.setConfig({ 5 | theme: themes.dark, 6 | }) -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import { themes } from '@storybook/theming' 2 | import { initialize, mswDecorator } from 'msw-storybook-addon'; 3 | import '../src/global.css' 4 | 5 | initialize(); 6 | 7 | export const decorators = [mswDecorator]; 8 | 9 | export const parameters = { 10 | actions: { argTypesRegex: "^on[A-Z].*" }, 11 | controls: { 12 | matchers: { 13 | color: /(background|color)$/i, 14 | date: /Date$/, 15 | }, 16 | }, 17 | docs: { 18 | theme: themes.dark, 19 | }, 20 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vite App 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "preview": "vite preview", 9 | "storybook": "start-storybook -p 6006", 10 | "build-storybook": "build-storybook" 11 | }, 12 | "dependencies": { 13 | "@headlessui/react": "^1.6.0", 14 | "@tailwindcss/forms": "^0.5.0", 15 | "axios": "^0.27.2", 16 | "html2canvas": "^1.4.1", 17 | "phosphor-react": "^1.4.1", 18 | "react": "^18.0.0", 19 | "react-dom": "^18.0.0", 20 | "tailwind-scrollbar": "^1.3.1" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "^7.19.3", 24 | "@storybook/addon-actions": "^6.5.12", 25 | "@storybook/addon-essentials": "^6.5.12", 26 | "@storybook/addon-interactions": "^6.5.12", 27 | "@storybook/addon-links": "^6.5.12", 28 | "@storybook/builder-vite": "^0.2.3", 29 | "@storybook/react": "^6.5.12", 30 | "@storybook/testing-library": "^0.0.13", 31 | "@types/react": "^18.0.0", 32 | "@types/react-dom": "^18.0.0", 33 | "@vitejs/plugin-react": "^1.3.0", 34 | "autoprefixer": "^10.4.6", 35 | "babel-loader": "^8.2.5", 36 | "msw": "^0.47.4", 37 | "msw-storybook-addon": "^1.6.3", 38 | "postcss": "^8.4.13", 39 | "tailwindcss": "^3.0.24", 40 | "typescript": "^4.6.3", 41 | "vite": "^3.1.6" 42 | }, 43 | "msw": { 44 | "workerDirectory": "public" 45 | } 46 | } -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | 4 | /** 5 | * Mock Service Worker (0.47.4). 6 | * @see https://github.com/mswjs/msw 7 | * - Please do NOT modify this file. 8 | * - Please do NOT serve this file on production. 9 | */ 10 | 11 | const INTEGRITY_CHECKSUM = 'b3066ef78c2f9090b4ce87e874965995' 12 | const activeClientIds = new Set() 13 | 14 | self.addEventListener('install', function () { 15 | self.skipWaiting() 16 | }) 17 | 18 | self.addEventListener('activate', function (event) { 19 | event.waitUntil(self.clients.claim()) 20 | }) 21 | 22 | self.addEventListener('message', async function (event) { 23 | const clientId = event.source.id 24 | 25 | if (!clientId || !self.clients) { 26 | return 27 | } 28 | 29 | const client = await self.clients.get(clientId) 30 | 31 | if (!client) { 32 | return 33 | } 34 | 35 | const allClients = await self.clients.matchAll({ 36 | type: 'window', 37 | }) 38 | 39 | switch (event.data) { 40 | case 'KEEPALIVE_REQUEST': { 41 | sendToClient(client, { 42 | type: 'KEEPALIVE_RESPONSE', 43 | }) 44 | break 45 | } 46 | 47 | case 'INTEGRITY_CHECK_REQUEST': { 48 | sendToClient(client, { 49 | type: 'INTEGRITY_CHECK_RESPONSE', 50 | payload: INTEGRITY_CHECKSUM, 51 | }) 52 | break 53 | } 54 | 55 | case 'MOCK_ACTIVATE': { 56 | activeClientIds.add(clientId) 57 | 58 | sendToClient(client, { 59 | type: 'MOCKING_ENABLED', 60 | payload: true, 61 | }) 62 | break 63 | } 64 | 65 | case 'MOCK_DEACTIVATE': { 66 | activeClientIds.delete(clientId) 67 | break 68 | } 69 | 70 | case 'CLIENT_CLOSED': { 71 | activeClientIds.delete(clientId) 72 | 73 | const remainingClients = allClients.filter((client) => { 74 | return client.id !== clientId 75 | }) 76 | 77 | // Unregister itself when there are no more clients 78 | if (remainingClients.length === 0) { 79 | self.registration.unregister() 80 | } 81 | 82 | break 83 | } 84 | } 85 | }) 86 | 87 | self.addEventListener('fetch', function (event) { 88 | const { request } = event 89 | const accept = request.headers.get('accept') || '' 90 | 91 | // Bypass server-sent events. 92 | if (accept.includes('text/event-stream')) { 93 | return 94 | } 95 | 96 | // Bypass navigation requests. 97 | if (request.mode === 'navigate') { 98 | return 99 | } 100 | 101 | // Opening the DevTools triggers the "only-if-cached" request 102 | // that cannot be handled by the worker. Bypass such requests. 103 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 104 | return 105 | } 106 | 107 | // Bypass all requests when there are no active clients. 108 | // Prevents the self-unregistered worked from handling requests 109 | // after it's been deleted (still remains active until the next reload). 110 | if (activeClientIds.size === 0) { 111 | return 112 | } 113 | 114 | // Generate unique request ID. 115 | const requestId = Math.random().toString(16).slice(2) 116 | 117 | event.respondWith( 118 | handleRequest(event, requestId).catch((error) => { 119 | if (error.name === 'NetworkError') { 120 | console.warn( 121 | '[MSW] Successfully emulated a network error for the "%s %s" request.', 122 | request.method, 123 | request.url, 124 | ) 125 | return 126 | } 127 | 128 | // At this point, any exception indicates an issue with the original request/response. 129 | console.error( 130 | `\ 131 | [MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, 132 | request.method, 133 | request.url, 134 | `${error.name}: ${error.message}`, 135 | ) 136 | }), 137 | ) 138 | }) 139 | 140 | async function handleRequest(event, requestId) { 141 | const client = await resolveMainClient(event) 142 | const response = await getResponse(event, client, requestId) 143 | 144 | // Send back the response clone for the "response:*" life-cycle events. 145 | // Ensure MSW is active and ready to handle the message, otherwise 146 | // this message will pend indefinitely. 147 | if (client && activeClientIds.has(client.id)) { 148 | ;(async function () { 149 | const clonedResponse = response.clone() 150 | sendToClient(client, { 151 | type: 'RESPONSE', 152 | payload: { 153 | requestId, 154 | type: clonedResponse.type, 155 | ok: clonedResponse.ok, 156 | status: clonedResponse.status, 157 | statusText: clonedResponse.statusText, 158 | body: 159 | clonedResponse.body === null ? null : await clonedResponse.text(), 160 | headers: Object.fromEntries(clonedResponse.headers.entries()), 161 | redirected: clonedResponse.redirected, 162 | }, 163 | }) 164 | })() 165 | } 166 | 167 | return response 168 | } 169 | 170 | // Resolve the main client for the given event. 171 | // Client that issues a request doesn't necessarily equal the client 172 | // that registered the worker. It's with the latter the worker should 173 | // communicate with during the response resolving phase. 174 | async function resolveMainClient(event) { 175 | const client = await self.clients.get(event.clientId) 176 | 177 | if (client.frameType === 'top-level') { 178 | return client 179 | } 180 | 181 | const allClients = await self.clients.matchAll({ 182 | type: 'window', 183 | }) 184 | 185 | return allClients 186 | .filter((client) => { 187 | // Get only those clients that are currently visible. 188 | return client.visibilityState === 'visible' 189 | }) 190 | .find((client) => { 191 | // Find the client ID that's recorded in the 192 | // set of clients that have registered the worker. 193 | return activeClientIds.has(client.id) 194 | }) 195 | } 196 | 197 | async function getResponse(event, client, requestId) { 198 | const { request } = event 199 | const clonedRequest = request.clone() 200 | 201 | function passthrough() { 202 | // Clone the request because it might've been already used 203 | // (i.e. its body has been read and sent to the client). 204 | const headers = Object.fromEntries(clonedRequest.headers.entries()) 205 | 206 | // Remove MSW-specific request headers so the bypassed requests 207 | // comply with the server's CORS preflight check. 208 | // Operate with the headers as an object because request "Headers" 209 | // are immutable. 210 | delete headers['x-msw-bypass'] 211 | 212 | return fetch(clonedRequest, { headers }) 213 | } 214 | 215 | // Bypass mocking when the client is not active. 216 | if (!client) { 217 | return passthrough() 218 | } 219 | 220 | // Bypass initial page load requests (i.e. static assets). 221 | // The absence of the immediate/parent client in the map of the active clients 222 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 223 | // and is not ready to handle requests. 224 | if (!activeClientIds.has(client.id)) { 225 | return passthrough() 226 | } 227 | 228 | // Bypass requests with the explicit bypass header. 229 | // Such requests can be issued by "ctx.fetch()". 230 | if (request.headers.get('x-msw-bypass') === 'true') { 231 | return passthrough() 232 | } 233 | 234 | // Notify the client that a request has been intercepted. 235 | const clientMessage = await sendToClient(client, { 236 | type: 'REQUEST', 237 | payload: { 238 | id: requestId, 239 | url: request.url, 240 | method: request.method, 241 | headers: Object.fromEntries(request.headers.entries()), 242 | cache: request.cache, 243 | mode: request.mode, 244 | credentials: request.credentials, 245 | destination: request.destination, 246 | integrity: request.integrity, 247 | redirect: request.redirect, 248 | referrer: request.referrer, 249 | referrerPolicy: request.referrerPolicy, 250 | body: await request.text(), 251 | bodyUsed: request.bodyUsed, 252 | keepalive: request.keepalive, 253 | }, 254 | }) 255 | 256 | switch (clientMessage.type) { 257 | case 'MOCK_RESPONSE': { 258 | return respondWithMock(clientMessage.data) 259 | } 260 | 261 | case 'MOCK_NOT_FOUND': { 262 | return passthrough() 263 | } 264 | 265 | case 'NETWORK_ERROR': { 266 | const { name, message } = clientMessage.data 267 | const networkError = new Error(message) 268 | networkError.name = name 269 | 270 | // Rejecting a "respondWith" promise emulates a network error. 271 | throw networkError 272 | } 273 | } 274 | 275 | return passthrough() 276 | } 277 | 278 | function sendToClient(client, message) { 279 | return new Promise((resolve, reject) => { 280 | const channel = new MessageChannel() 281 | 282 | channel.port1.onmessage = (event) => { 283 | if (event.data && event.data.error) { 284 | return reject(event.data.error) 285 | } 286 | 287 | resolve(event.data) 288 | } 289 | 290 | client.postMessage(message, [channel.port2]) 291 | }) 292 | } 293 | 294 | function sleep(timeMs) { 295 | return new Promise((resolve) => { 296 | setTimeout(resolve, timeMs) 297 | }) 298 | } 299 | 300 | async function respondWithMock(response) { 301 | await sleep(response.delay) 302 | return new Response(response.body, response) 303 | } 304 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Widget } from "./components/Widget" 2 | 3 | export function App() { 4 | return ( 5 | 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /src/assets/bug.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/idea.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/thought.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/CloseButton.tsx: -------------------------------------------------------------------------------- 1 | import { Popover } from '@headlessui/react' 2 | import { X } from 'phosphor-react' 3 | 4 | export function CloseButton() { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } -------------------------------------------------------------------------------- /src/components/Loading.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react' 2 | import { Loading } from './Loading' 3 | 4 | export default { 5 | title: 'Components/Loading', 6 | component: Loading, 7 | } as Meta 8 | 9 | export const Default: StoryObj = {} -------------------------------------------------------------------------------- /src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { CircleNotch } from "phosphor-react"; 2 | 3 | export function Loading() { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } -------------------------------------------------------------------------------- /src/components/Widget.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Meta, StoryObj } from '@storybook/react' 2 | import { Widget } from './Widget' 3 | 4 | export default { 5 | title: 'Widget', 6 | component: Widget, 7 | } as Meta 8 | 9 | export const Default: StoryObj = {} -------------------------------------------------------------------------------- /src/components/Widget.tsx: -------------------------------------------------------------------------------- 1 | import { ChatTeardropDots } from 'phosphor-react' 2 | import { Popover } from '@headlessui/react' 3 | import { WidgetForm } from './WidgetForm' 4 | 5 | export function Widget() { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | Feedback 20 | 21 | 22 | 23 | ) 24 | } -------------------------------------------------------------------------------- /src/components/WidgetForm/ScreenshotButton.tsx: -------------------------------------------------------------------------------- 1 | import { Camera, Trash } from "phosphor-react"; 2 | import html2canvas from 'html2canvas' 3 | import { useState } from "react"; 4 | import { Loading } from "../Loading"; 5 | 6 | interface FeedbackTypeStepProps { 7 | onScreenshotTaken: (screenshot: string | null) => void; 8 | screenshot: string | null; 9 | } 10 | 11 | export function ScreenshotButton({ onScreenshotTaken, screenshot }: FeedbackTypeStepProps) { 12 | const [isTakingScreenshot, setIsTakingScreenshot] = useState(false) 13 | 14 | async function handleTakeScreenshot() { 15 | setIsTakingScreenshot(true); 16 | 17 | const canvas = await html2canvas(document.querySelector('html')!) 18 | const base64image = canvas.toDataURL('image/png') 19 | 20 | onScreenshotTaken(base64image) 21 | setIsTakingScreenshot(false) 22 | } 23 | 24 | if (screenshot) { 25 | return ( 26 | 38 | ) 39 | } 40 | 41 | return ( 42 | 49 | ) 50 | } -------------------------------------------------------------------------------- /src/components/WidgetForm/Steps/FeedbackContentStep.stories.tsx: -------------------------------------------------------------------------------- 1 | import { Popover } from '@headlessui/react' 2 | import { Meta, StoryObj } from '@storybook/react' 3 | import { rest } from 'msw' 4 | import { feedbackTypes } from '..' 5 | import { FeedbackContentStep, FeedbackContentStepProps } from './FeedbackContentStep' 6 | 7 | export default { 8 | title: 'Widget UI/FeedbackContentStep', 9 | component: FeedbackContentStep, 10 | args: { 11 | feedbackType: 'IDEA' 12 | }, 13 | parameters: { 14 | msw: { 15 | handlers: [ 16 | rest.post('/feedbacks', (req, res) => { 17 | return res() 18 | }) 19 | ], 20 | } 21 | }, 22 | argTypes: { 23 | feedbackType: { 24 | options: Object.keys(feedbackTypes), 25 | control: { 26 | type: 'inline-radio' 27 | } 28 | } 29 | }, 30 | decorators: [ 31 | (Story) => { 32 | return ( 33 | 34 | 35 |
36 | {Story()} 37 |
38 |
39 |
40 | ) 41 | } 42 | ], 43 | } as Meta 44 | 45 | export const Bug: StoryObj = { 46 | args: { 47 | feedbackType: 'BUG', 48 | }, 49 | } 50 | 51 | export const Idea: StoryObj = { 52 | args: { 53 | feedbackType: 'IDEA', 54 | }, 55 | } 56 | 57 | export const Other: StoryObj = { 58 | args: { 59 | feedbackType: 'OTHER', 60 | }, 61 | } -------------------------------------------------------------------------------- /src/components/WidgetForm/Steps/FeedbackContentStep.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLeft } from "phosphor-react"; 2 | import { FormEvent, useState } from "react"; 3 | import { FeedbackType, feedbackTypes } from ".."; 4 | import { api } from "../../../lib/api"; 5 | import { CloseButton } from "../../CloseButton" 6 | import { Loading } from "../../Loading"; 7 | import { ScreenshotButton } from "../ScreenshotButton"; 8 | 9 | export interface FeedbackContentStepProps { 10 | feedbackType: FeedbackType; 11 | onFeedbackRestartRequested: () => void; 12 | onFeedbackSent: () => void; 13 | } 14 | 15 | export function FeedbackContentStep({ 16 | feedbackType, 17 | onFeedbackRestartRequested, 18 | onFeedbackSent, 19 | }: FeedbackContentStepProps) { 20 | const [screenshot, setScreenshot] = useState(null) 21 | const [comment, setComment] = useState('') 22 | const [isSendingFeedback, setIsSendingFeedback] = useState(false); 23 | 24 | const feedbackTypeInfo = feedbackTypes[feedbackType]; 25 | 26 | async function handleSubmitFeedback(event: FormEvent) { 27 | event.preventDefault(); 28 | 29 | setIsSendingFeedback(true); 30 | 31 | // console.log({ 32 | // screenshot, 33 | // comment, 34 | // }) 35 | 36 | await api.post('/feedbacks', { 37 | type: feedbackType, 38 | comment, 39 | screenshot, 40 | }); 41 | 42 | setIsSendingFeedback(false); 43 | onFeedbackSent(); 44 | } 45 | 46 | return ( 47 | <> 48 |
49 | 57 | 58 | {feedbackTypeInfo.image.alt} 59 | {feedbackTypeInfo.title} 60 | 61 | 62 | 63 |
64 | 65 |
66 |