Openstatus www.openstatus.dev

feat: plain support bubble (#858)

* wip:

* chore: update plain support

* chore: support optional

* fix: styles

* ⚡ small update

* 🔥 plain

---------

Co-authored-by: Thibault Le Ouay <thibaultleouay@gmail.Com>

authored by

Maximilian Kaske
Thibault Le Ouay
and committed by
GitHub
422757c7 3265afdf

+427 -1
+2
apps/web/.env.example
··· 71 71 AUTH_GITHUB_SECRET= 72 72 AUTH_GOOGLE_ID= 73 73 AUTH_GOOGLE_SECRET= 74 + 75 + PLAIN_API_KEY=
+1
apps/web/package.json
··· 41 41 "@tailwindcss/container-queries": "0.1.1", 42 42 "@tailwindcss/typography": "0.5.10", 43 43 "@tanstack/react-table": "8.10.3", 44 + "@team-plain/typescript-sdk": "4.2.3", 44 45 "@tremor/react": "3.13.3", 45 46 "@trpc/client": "10.45.1", 46 47 "@trpc/next": "10.45.1",
+3
apps/web/src/app/app/layout.tsx
··· 2 2 3 3 import { PHProvider, PostHogPageview } from "@/providers/posthog"; 4 4 5 + import { Bubble } from "@/components/support/bubble"; 6 + 5 7 export default function AuthLayout({ 6 8 children, // will be a page or nested layout 7 9 }: { ··· 11 13 <PHProvider> 12 14 <PostHogPageview /> 13 15 <SessionProvider>{children}</SessionProvider> 16 + <Bubble /> 14 17 </PHProvider> 15 18 ); 16 19 }
+71
apps/web/src/components/support/action.ts
··· 1 + "use server"; 2 + 3 + import { PlainClient } from "@team-plain/typescript-sdk"; 4 + 5 + import { env } from "@/env"; 6 + 7 + import type { FormValues } from "./contact-form"; 8 + 9 + const labelTypeIds = { 10 + bug: "lt_01HZDA8FCA0CMPCJ2YSTVE78XN", 11 + demo: "lt_01HZDA8N33R1A9AN97RGXPZ93F", 12 + feature: "lt_01HZDA8V56431V27GFDAVV58FX", 13 + question: "lt_01HZDA8XWTY0SWY4MKNY6F4EVK", 14 + security: "lt_01HZDA91M7KVR4529FHVHE18MG", 15 + }; 16 + 17 + function getPriority(type: FormValues["type"], isBlocking: boolean) { 18 + if (type === "security") return 0; 19 + if (type === "bug" && isBlocking) return 1; 20 + return 2; 21 + } 22 + 23 + function isString(s: unknown): s is string { 24 + return typeof s === "string"; 25 + } 26 + 27 + const client = new PlainClient({ 28 + apiKey: env.PLAIN_API_KEY || "", 29 + }); 30 + 31 + export async function handlePlainSupport(values: FormValues) { 32 + const upsertCustomerRes = await client.upsertCustomer({ 33 + identifier: { 34 + emailAddress: values.email, 35 + }, 36 + onCreate: { 37 + fullName: values.name, 38 + email: { 39 + email: values.email, 40 + isVerified: true, 41 + }, 42 + }, 43 + onUpdate: {}, 44 + }); 45 + 46 + if (upsertCustomerRes.error) { 47 + console.error(upsertCustomerRes.error); 48 + return { error: upsertCustomerRes.error.message }; 49 + } 50 + 51 + console.log(`Customer upserted ${upsertCustomerRes.data.customer.id}`); 52 + 53 + const createThreadRes = await client.createThread({ 54 + customerIdentifier: { 55 + customerId: upsertCustomerRes.data.customer.id, 56 + }, 57 + title: "Support request!", 58 + components: [{ componentText: { text: values.message } }], 59 + labelTypeIds: [labelTypeIds[values.type]].filter(isString), 60 + priority: getPriority(values.type, values.blocker), 61 + }); 62 + 63 + if (createThreadRes.error) { 64 + console.error(createThreadRes.error); 65 + return { error: createThreadRes.error.message }; 66 + } 67 + 68 + console.log(`Thread created ${createThreadRes.data.id}`); 69 + 70 + return { error: null }; 71 + }
+85
apps/web/src/components/support/bubble.tsx
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + import { MessageCircle } from "lucide-react"; 5 + 6 + import { 7 + Button, 8 + Popover, 9 + PopoverContent, 10 + PopoverTrigger, 11 + } from "@openstatus/ui"; 12 + 13 + import { ContactForm } from "./contact-form"; 14 + 15 + export function Bubble() { 16 + const [open, setOpen] = useState(false); 17 + const [formVisible, setFormVisible] = useState(false); 18 + 19 + return ( 20 + <div className="fixed right-4 bottom-4 z-50 rounded-full bg-background"> 21 + <Popover 22 + open={open} 23 + onOpenChange={(value) => { 24 + // TODO: improve as if you do it quickly, it will still be visible and jump 25 + if (formVisible && !value) { 26 + setTimeout(() => setFormVisible(false), 300); // reset form after popover closes 27 + } 28 + setOpen(value); 29 + }} 30 + > 31 + <PopoverTrigger className="rounded-full border p-2 shadow"> 32 + <MessageCircle className="h-6 w-6" /> 33 + </PopoverTrigger> 34 + <PopoverContent 35 + side="top" 36 + sideOffset={8} 37 + align="end" 38 + alignOffset={0} 39 + className={formVisible ? "w-80" : undefined} 40 + > 41 + {!formVisible ? ( 42 + <div className="space-y-2"> 43 + <p className="font-medium text-foreground">Need help?</p> 44 + <p className="text-muted-foreground text-sm"> 45 + We are here to help you with any questions you may have. 46 + </p> 47 + <Button variant="ghost" className="w-full" asChild> 48 + <a 49 + target="_blank" 50 + rel="noreferrer" 51 + href="https://cal.com/team/openstatus/30min" 52 + > 53 + Book a call 54 + </a> 55 + </Button> 56 + <Button variant="ghost" className="w-full" asChild> 57 + <a 58 + target="_blank" 59 + rel="noreferrer" 60 + href="https://docs.openstatus.dev" 61 + > 62 + Browse documentation 63 + </a> 64 + </Button> 65 + <Button variant="ghost" className="w-full" asChild> 66 + <a target="_blank" rel="noreferrer" href="/discord"> 67 + Join Discord 68 + </a> 69 + </Button> 70 + <Button 71 + variant="ghost" 72 + className="w-full" 73 + onClick={() => setFormVisible((prev) => !prev)} 74 + > 75 + Get in touch 76 + </Button> 77 + </div> 78 + ) : ( 79 + <ContactForm onSubmit={() => setOpen(false)} /> 80 + )} 81 + </PopoverContent> 82 + </Popover> 83 + </div> 84 + ); 85 + }
+188
apps/web/src/components/support/contact-form.tsx
··· 1 + "use client"; 2 + 3 + import { useTransition } from "react"; 4 + import { zodResolver } from "@hookform/resolvers/zod"; 5 + import { useForm } from "react-hook-form"; 6 + import { z } from "zod"; 7 + 8 + import { 9 + Button, 10 + Checkbox, 11 + Form, 12 + FormControl, 13 + FormField, 14 + FormItem, 15 + FormLabel, 16 + FormMessage, 17 + Input, 18 + Select, 19 + SelectContent, 20 + SelectItem, 21 + SelectTrigger, 22 + SelectValue, 23 + Textarea, 24 + } from "@openstatus/ui"; 25 + 26 + import { LoadingAnimation } from "../loading-animation"; 27 + import { handlePlainSupport } from "./action"; 28 + import { toast } from "@/lib/toast"; 29 + 30 + export const types = [ 31 + { 32 + label: "Report a bug", 33 + value: "bug" as const, 34 + }, 35 + { 36 + label: "Book a demo", 37 + value: "demo" as const, 38 + }, 39 + { 40 + label: "Suggest a feature", 41 + value: "feature" as const, 42 + }, 43 + { 44 + label: "Report a security issue", 45 + value: "security" as const, 46 + }, 47 + { 48 + label: "Something else", 49 + value: "question" as const, 50 + }, 51 + ]; 52 + 53 + export const FormSchema = z.object({ 54 + name: z.string().min(1), 55 + type: z.enum(["bug", "demo", "feature", "security", "question"]), 56 + email: z.string().email(), 57 + message: z.string().min(1), 58 + blocker: z.boolean().optional().default(false), 59 + }); 60 + 61 + export type FormValues = z.infer<typeof FormSchema>; 62 + 63 + interface ContactFormProps { 64 + defaultValues?: FormValues; 65 + onSubmit?: () => void; 66 + } 67 + 68 + export function ContactForm({ 69 + defaultValues, 70 + onSubmit: handleSubmit, 71 + }: ContactFormProps) { 72 + const form = useForm<FormValues>({ 73 + resolver: zodResolver(FormSchema), 74 + defaultValues, 75 + }); 76 + const [isPending, startTransition] = useTransition(); 77 + 78 + async function onSubmit(data: FormValues) { 79 + startTransition(async () => { 80 + const result = await handlePlainSupport(data); 81 + if (result.error) { 82 + console.error(result.error); 83 + toast.error("Something went wrong. Please try again."); 84 + } else { 85 + handleSubmit?.(); 86 + toast.success( 87 + "Your message has been sent! We will get back to you soon." 88 + ); 89 + } 90 + }); 91 + } 92 + 93 + const watchType = form.watch("type"); 94 + 95 + return ( 96 + <Form {...form}> 97 + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3"> 98 + <FormField 99 + control={form.control} 100 + name="name" 101 + render={({ field }) => ( 102 + <FormItem> 103 + <FormLabel>Name</FormLabel> 104 + <FormControl> 105 + <Input placeholder="Max" {...field} /> 106 + </FormControl> 107 + <FormMessage /> 108 + </FormItem> 109 + )} 110 + /> 111 + <FormField 112 + control={form.control} 113 + name="email" 114 + render={({ field }) => ( 115 + <FormItem> 116 + <FormLabel>Email</FormLabel> 117 + <FormControl> 118 + <Input placeholder="max@openstatus.dev" {...field} /> 119 + </FormControl> 120 + <FormMessage /> 121 + </FormItem> 122 + )} 123 + /> 124 + <FormField 125 + control={form.control} 126 + name="type" 127 + render={({ field }) => ( 128 + <FormItem> 129 + <FormLabel>Type</FormLabel> 130 + <Select onValueChange={field.onChange} defaultValue={field.value}> 131 + <FormControl> 132 + <SelectTrigger> 133 + <SelectValue placeholder="What you need help with" /> 134 + </SelectTrigger> 135 + </FormControl> 136 + <SelectContent> 137 + {types.map((type) => ( 138 + <SelectItem key={type.value} value={type.value}> 139 + {type.label} 140 + </SelectItem> 141 + ))} 142 + </SelectContent> 143 + </Select> 144 + <FormMessage /> 145 + </FormItem> 146 + )} 147 + /> 148 + {watchType ? ( 149 + <FormField 150 + control={form.control} 151 + name="message" 152 + render={({ field }) => ( 153 + <FormItem> 154 + <FormLabel>Message</FormLabel> 155 + <FormControl> 156 + <Textarea placeholder="Tell us about it..." {...field} /> 157 + </FormControl> 158 + <FormMessage /> 159 + </FormItem> 160 + )} 161 + /> 162 + ) : null} 163 + {watchType === "bug" ? ( 164 + <FormField 165 + control={form.control} 166 + name="blocker" 167 + render={({ field }) => ( 168 + <FormItem className="flex flex-row items-start space-x-2 space-y-0"> 169 + <FormControl> 170 + <Checkbox 171 + checked={field.value} 172 + onCheckedChange={field.onChange} 173 + /> 174 + </FormControl> 175 + <FormLabel className="font-normal leading-none"> 176 + This bug prevents me from using the product. 177 + </FormLabel> 178 + </FormItem> 179 + )} 180 + /> 181 + ) : null} 182 + <Button type="submit" className="w-full" disabled={isPending}> 183 + {isPending ? <LoadingAnimation /> : "Submit"} 184 + </Button> 185 + </form> 186 + </Form> 187 + ); 188 + }
+2
apps/web/src/env.ts
··· 23 23 CLICKHOUSE_URL: z.string(), 24 24 CLICKHOUSE_USERNAME: z.string(), 25 25 CLICKHOUSE_PASSWORD: z.string(), 26 + PLAIN_API_KEY: z.string().optional(), 26 27 }, 27 28 client: { 28 29 NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string(), ··· 55 56 CLICKHOUSE_URL: process.env.CLICKHOUSE_URL, 56 57 CLICKHOUSE_USERNAME: process.env.CLICKHOUSE_USERNAME, 57 58 CLICKHOUSE_PASSWORD: process.env.CLICKHOUSE_PASSWORD, 59 + PLAIN_API_KEY: process.env.PLAIN_API_KEY, 58 60 }, 59 61 skipValidation: true, 60 62 });
+75 -1
pnpm-lock.yaml
··· 317 317 '@tanstack/react-table': 318 318 specifier: 8.10.3 319 319 version: 8.10.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) 320 + '@team-plain/typescript-sdk': 321 + specifier: 4.2.3 322 + version: 4.2.3 320 323 '@tremor/react': 321 324 specifier: 3.13.3 322 325 version: 3.13.3(prop-types@15.8.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(tailwindcss@3.4.3(ts-node@10.9.1(@types/node@20.8.0)(typescript@5.4.5))) ··· 2185 2188 resolution: {integrity: sha512-vwj0jzkHNgY/WvqrNt763QfO3mRHIO1GSFW22scOneI5pcu5sMb/JPN+P+cDPTEXRNPdZCaZ/HicLHZNxbseyA==} 2186 2189 engines: {node: '>=v14'} 2187 2190 2191 + '@graphql-typed-document-node/core@3.2.0': 2192 + resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} 2193 + peerDependencies: 2194 + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 2195 + 2188 2196 '@grpc/grpc-js@1.9.7': 2189 2197 resolution: {integrity: sha512-yMaA/cIsRhGzW3ymCNpdlPcInXcovztlgu/rirThj2b87u3RzWUszliOqZ/pldy7yhmJPS8uwog+kZSTa4A0PQ==} 2190 2198 engines: {node: ^8.13.0 || >=10.10.0} ··· 3770 3778 '@tanstack/virtual-core@3.0.0': 3771 3779 resolution: {integrity: sha512-SYXOBTjJb05rXa2vl55TTwO40A6wKu0R5i1qQwhJYNDIqaIGF7D0HsLw+pJAyi2OvntlEIVusx3xtbbgSUi6zg==} 3772 3780 3781 + '@team-plain/typescript-sdk@4.2.3': 3782 + resolution: {integrity: sha512-aJjN7qXc07t4aJzf55CsiRd6o5OD7hLAEvsG0qMWOSCBq2O802SSucPmHTufAMo5r5/TVLpFVz+aR29nXwW4xg==} 3783 + 3773 3784 '@tootallnate/once@2.0.0': 3774 3785 resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} 3775 3786 engines: {node: '>= 10'} ··· 4065 4076 resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} 4066 4077 engines: {node: '>=8'} 4067 4078 4079 + ajv-formats@2.1.1: 4080 + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} 4081 + peerDependencies: 4082 + ajv: ^8.0.0 4083 + peerDependenciesMeta: 4084 + ajv: 4085 + optional: true 4086 + 4087 + ajv@8.14.0: 4088 + resolution: {integrity: sha512-oYs1UUtO97ZO2lJ4bwnWeQW8/zvOIQLGKcvPTsWmvc2SYgBb+upuNS5NxoLaMU4h8Ju3Nbj6Cq8mD2LQoqVKFA==} 4089 + 4068 4090 analytics-utils@1.0.12: 4069 4091 resolution: {integrity: sha512-WvV2YWgsnXLxaY0QYux0crpBAg/0JA763NmbMVz22jKhMPo7dpTBet8G2IlF7ixTjLDzGlkHk1ZaKqqQmjJ+4w==} 4070 4092 peerDependencies: ··· 5106 5128 resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} 5107 5129 engines: {node: '>=4'} 5108 5130 5131 + fast-deep-equal@3.1.3: 5132 + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} 5133 + 5109 5134 fast-equals@5.0.1: 5110 5135 resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} 5111 5136 engines: {node: '>=6.0.0'} ··· 5327 5352 gradient-string@2.0.2: 5328 5353 resolution: {integrity: sha512-rEDCuqUQ4tbD78TpzsMtt5OIf0cBCSDWSJtUDaF6JsAh+k0v9r++NzxNEG87oDZx9ZwGhD8DaezR2L/yrw0Jdw==} 5329 5354 engines: {node: '>=10'} 5355 + 5356 + graphql@16.8.1: 5357 + resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} 5358 + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} 5330 5359 5331 5360 gray-matter@4.0.3: 5332 5361 resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} ··· 5828 5857 json-parse-even-better-errors@2.3.1: 5829 5858 resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} 5830 5859 5860 + json-schema-traverse@1.0.0: 5861 + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} 5862 + 5831 5863 jsonc-parser@3.2.0: 5832 5864 resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} 5833 5865 ··· 7041 7073 resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} 7042 7074 engines: {node: '>=0.10.0'} 7043 7075 7076 + require-from-string@2.0.2: 7077 + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} 7078 + engines: {node: '>=0.10.0'} 7079 + 7044 7080 requires-port@1.0.0: 7045 7081 resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} 7046 7082 ··· 7787 7823 upper-case@1.1.3: 7788 7824 resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==} 7789 7825 7826 + uri-js@4.4.1: 7827 + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} 7828 + 7790 7829 url-parse@1.5.10: 7791 7830 resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} 7792 7831 ··· 9273 9312 transitivePeerDependencies: 9274 9313 - encoding 9275 9314 - supports-color 9315 + 9316 + '@graphql-typed-document-node/core@3.2.0(graphql@16.8.1)': 9317 + dependencies: 9318 + graphql: 16.8.1 9276 9319 9277 9320 '@grpc/grpc-js@1.9.7': 9278 9321 dependencies: ··· 11229 11272 11230 11273 '@tanstack/virtual-core@3.0.0': {} 11231 11274 11275 + '@team-plain/typescript-sdk@4.2.3': 11276 + dependencies: 11277 + '@graphql-typed-document-node/core': 3.2.0(graphql@16.8.1) 11278 + ajv: 8.14.0 11279 + ajv-formats: 2.1.1(ajv@8.14.0) 11280 + graphql: 16.8.1 11281 + zod: 3.22.4 11282 + 11232 11283 '@tootallnate/once@2.0.0': {} 11233 11284 11234 11285 '@tootallnate/quickjs-emscripten@0.23.0': {} ··· 11562 11613 clean-stack: 2.2.0 11563 11614 indent-string: 4.0.0 11564 11615 11616 + ajv-formats@2.1.1(ajv@8.14.0): 11617 + optionalDependencies: 11618 + ajv: 8.14.0 11619 + 11620 + ajv@8.14.0: 11621 + dependencies: 11622 + fast-deep-equal: 3.1.3 11623 + json-schema-traverse: 1.0.0 11624 + require-from-string: 2.0.2 11625 + uri-js: 4.4.1 11626 + 11565 11627 analytics-utils@1.0.12(@types/dlv@1.1.4): 11566 11628 dependencies: 11567 11629 '@analytics/type-utils': 0.6.2 ··· 12562 12624 iconv-lite: 0.4.24 12563 12625 tmp: 0.0.33 12564 12626 12627 + fast-deep-equal@3.1.3: {} 12628 + 12565 12629 fast-equals@5.0.1: {} 12566 12630 12567 12631 fast-glob@3.3.1: ··· 12849 12913 dependencies: 12850 12914 chalk: 4.1.2 12851 12915 tinygradient: 1.1.5 12916 + 12917 + graphql@16.8.1: {} 12852 12918 12853 12919 gray-matter@4.0.3: 12854 12920 dependencies: ··· 13461 13527 13462 13528 json-parse-even-better-errors@2.3.1: {} 13463 13529 13530 + json-schema-traverse@1.0.0: {} 13531 + 13464 13532 jsonc-parser@3.2.0: {} 13465 13533 13466 13534 jsonfile@4.0.0: ··· 15074 15142 15075 15143 require-directory@2.1.1: {} 15076 15144 15145 + require-from-string@2.0.2: {} 15146 + 15077 15147 requires-port@1.0.0: {} 15078 15148 15079 15149 resend@1.1.0: ··· 15482 15552 detective: 5.2.1 15483 15553 didyoumean: 1.2.2 15484 15554 dlv: 1.1.3 15485 - fast-glob: 3.3.1 15555 + fast-glob: 3.3.2 15486 15556 glob-parent: 6.0.2 15487 15557 is-glob: 4.0.3 15488 15558 lilconfig: 2.1.0 ··· 15883 15953 upper-case: 1.1.3 15884 15954 15885 15955 upper-case@1.1.3: {} 15956 + 15957 + uri-js@4.4.1: 15958 + dependencies: 15959 + punycode: 2.3.0 15886 15960 15887 15961 url-parse@1.5.10: 15888 15962 dependencies: