Add close on date functionality for surveys (#463)

* added date-picker

* added state and handlers for closeOnDate

* closeDate -> closeOnDate

* added endpoint for CRON to close surveys

* resolved migrations

* fixed datetime format

* removed warnings

* PR review changes

* resolved merge conflicts and package update

* add github workflow for cron

* change migration order

* change migration order

* add zod types for closeOnDate

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Piyush Gupta
2023-07-11 16:17:35 +05:30
committed by GitHub
parent af1b29f8bc
commit c8c84d0148
20 changed files with 315 additions and 66 deletions

View File

@@ -97,4 +97,7 @@ GITHUB_SECRET=
# Configure Google Login
NEXT_PUBLIC_GOOGLE_AUTH_ENABLED=0
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_CLIENT_SECRET=
# Cron Secret
CRON_SECRET=

View File

@@ -105,4 +105,4 @@ NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
# Cron Secret
CRON_SECRET=
CRON_SECRET=

23
.github/workflows/cron-closeOnDate.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Cron - weeklySummary
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “At 00:00.” (see https://crontab.guru)
- cron: "0 0 * * *"
jobs:
cron-weeklySummary:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/close_surveys \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_SECRET }}' \
--fail

View File

@@ -13,7 +13,7 @@ export const meta = {
description="Add a new webhook."
headers={[
{
label: "X-Api-Key",
label: "x-Api-Key",
type: "string",
description: "Your Formbricks API key.",
required: true,
@@ -82,7 +82,7 @@ export const meta = {
"code": "not_authenticated",
"message": "Not authenticated",
"details": {
"X-Api-Key": "Header not provided or API Key invalid"
"x-Api-Key": "Header not provided or API Key invalid"
}
}`,
},

View File

@@ -13,7 +13,7 @@ export const meta = {
description="Delete a specific webhook by its ID."
headers={[
{
label: "X-Api-Key",
label: "x-Api-Key",
type: "string",
description: "Your Formbricks API key.",
required: true,
@@ -45,7 +45,7 @@ export const meta = {
"code": "not_authenticated",
"message": "Not authenticated",
"details": {
"X-Api-Key": "Header not provided or API Key invalid"
"x-Api-Key": "Header not provided or API Key invalid"
}
}`,
},

View File

@@ -13,7 +13,7 @@ export const meta = {
description="Retrieve a specific webhook by its ID."
headers={[
{
label: "X-Api-Key",
label: "x-Api-Key",
type: "string",
description: "Your Formbricks API key.",
required: true,
@@ -45,7 +45,7 @@ export const meta = {
"code": "not_authenticated",
"message": "Not authenticated",
"details": {
"X-Api-Key": "Header not provided or API Key invalid"
"x-Api-Key": "Header not provided or API Key invalid"
}
}`,
},

View File

@@ -13,7 +13,7 @@ export const meta = {
description="Retrieve a list of all webhooks."
headers={[
{
label: "X-Api-Key",
label: "x-Api-Key",
type: "string",
description: "Your Formbricks API key.",
required: true,
@@ -47,7 +47,7 @@ export const meta = {
"code": "not_authenticated",
"message": "Not authenticated",
"details": {
"X-Api-Key": "Header not provided or API Key invalid"
"x-Api-Key": "Header not provided or API Key invalid"
}
}`,
},

View File

@@ -0,0 +1,43 @@
import { responses } from "@/lib/api/response";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
export async function POST() {
const headersList = headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey || apiKey !== process.env.CRON_SECRET) {
return responses.notAuthenticatedResponse();
}
const surveys = await prisma.survey.findMany({
where: {
status: "inProgress",
closeOnDate: {
lte: new Date(),
},
},
select: {
id: true,
},
});
if (!surveys.length) {
return responses.successResponse({ message: "No surveys to close" });
}
const mutationResp = await prisma.survey.updateMany({
where: {
id: {
in: surveys.map((survey) => survey.id),
},
},
data: {
status: "completed",
},
});
return responses.successResponse({
message: `Closed ${mutationResp.count} survey(s)`,
});
}

View File

@@ -1,7 +1,7 @@
"use client";
import type { Survey } from "@formbricks/types/surveys";
import { Input, Label, Switch } from "@formbricks/ui";
import { Input, Label, Switch, DatePicker } from "@formbricks/ui";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useEffect, useState } from "react";
@@ -15,7 +15,10 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
const [open, setOpen] = useState(false);
const autoComplete = localSurvey.autoComplete !== null;
const [redirectToggle, setRedirectToggle] = useState(false);
const [surveyCloseOnDateToggle, setSurveyCloseOnDateToggle] = useState(false);
const [redirectUrl, setRedirectUrl] = useState<string | null>("");
const [closeOnDate, setCloseOnDate] = useState<Date>();
const handleRedirectCheckMark = () => {
if (redirectToggle && localSurvey.redirectUrl) {
@@ -31,17 +34,46 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
setRedirectToggle(true);
};
const handleSurveyCloseOnDateToggle = () => {
if (surveyCloseOnDateToggle && localSurvey.closeOnDate) {
setSurveyCloseOnDateToggle(false);
setCloseOnDate(undefined);
setLocalSurvey({ ...localSurvey, closeOnDate: null });
return;
}
if (surveyCloseOnDateToggle) {
setSurveyCloseOnDateToggle(false);
return;
}
setSurveyCloseOnDateToggle(true);
};
const handleRedirectUrlChange = (link: string) => {
setRedirectUrl(link);
setLocalSurvey({ ...localSurvey, redirectUrl: link });
};
const handleCloseOnDateChange = (date: Date) => {
const equivalentDate = date?.getDate();
date?.setUTCHours(0, 0, 0, 0);
date?.setDate(equivalentDate);
setCloseOnDate(date);
setLocalSurvey({ ...localSurvey, closeOnDate: date ?? null });
};
useEffect(() => {
if (localSurvey.redirectUrl) {
setRedirectUrl(localSurvey.redirectUrl);
setRedirectToggle(true);
}
if (localSurvey.closeOnDate) {
setCloseOnDate(localSurvey.closeOnDate);
setSurveyCloseOnDateToggle(true);
}
}, []);
const handleCheckMark = () => {
if (autoComplete) {
const updatedSurvey: Survey = { ...localSurvey, autoComplete: null };
@@ -112,31 +144,54 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
</label>
</div>
)}
{localSurvey.type === "link" && (
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch id="redirectUrl" checked={redirectToggle} onCheckedChange={handleRedirectCheckMark} />
<Label htmlFor="redirectUrl" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Redirect on completion</h3>
<p className="text-xs font-normal text-slate-500">
Redirect user to specified link on survey completion
</p>
</div>
</Label>
</div>
<div className="mt-4">
{redirectToggle && (
<Input
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
/>
)}
</div>
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch id="redirectUrl" checked={redirectToggle} onCheckedChange={handleRedirectCheckMark} />
<Label htmlFor="redirectUrl" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Redirect on completion</h3>
<p className="text-xs font-normal text-slate-500">
Redirect user to specified link on survey completion
</p>
</div>
</Label>
</div>
)}
{redirectToggle && (
<div className="mt-4">
<Input
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
/>
</div>
)}
</div>
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch
id="surveyDeadline"
checked={surveyCloseOnDateToggle}
onCheckedChange={handleSurveyCloseOnDateToggle}
/>
<Label htmlFor="surveyDeadline" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Close survey on date</h3>
{localSurvey.status === "completed" && (
<p className="text-xs font-normal text-slate-500">
This form is already completed. You can change the status settings to make best use of
this option.
</p>
)}
</div>
</Label>
</div>
{surveyCloseOnDateToggle && (
<div className="mt-4">
<DatePicker date={closeOnDate} handleDateChange={handleCloseOnDateChange} />
</div>
)}
</div>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>

View File

@@ -92,7 +92,7 @@ const notAuthenticatedResponse = (cors: boolean = false) =>
code: "not_authenticated",
message: "Not authenticated",
details: {
"X-Api-Key": "Header not provided or API Key invalid",
"x-Api-Key": "Header not provided or API Key invalid",
},
} as ApiErrorResponse,
{

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "closeOnDate" TIMESTAMP(3);

View File

@@ -236,6 +236,7 @@ model Survey {
autoClose Int?
delay Int @default(0)
autoComplete Int?
closeOnDate DateTime?
}
model Event {

View File

@@ -20,8 +20,10 @@ const selectSurvey = {
displayOption: true,
recontactDays: true,
autoClose: true,
closeOnDate: true,
delay: true,
autoComplete: true,
closeOnDate: true,
triggers: {
select: {
eventClass: {

View File

@@ -26,6 +26,7 @@ export interface Survey {
autoClose: number | null;
delay: number;
autoComplete: number | null;
closeOnDate: Date | null;
}
export interface AttributeFilter {

View File

@@ -205,6 +205,7 @@ export const ZSurvey = z.object({
thankYouCard: ZSurveyThankYouCard,
delay: z.number(),
autoComplete: z.union([z.number(), z.null()]),
closeOnDate: z.date().nullable(),
});
export type TSurvey = z.infer<typeof ZSurvey>;

View File

@@ -0,0 +1,55 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@formbricks/lib/cn";
// import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
"hover:text-slate-700 hover:bg-slate-200 flex justify-center items-center rounded-md transition-colors duration-150 ease-in-out h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-slate-500 rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "text-center text-sm p-0 relative [&:has([aria-selected])]:bg-slate-200 first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn("hover:bg-slate-200 rounded-md p-0", "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_selected: "bg-black text-white aria-selected:bg-black aria-selected:text-white",
day_today: "bg-slate-200 aria-selected:bg-black aria-selected:text-white",
day_outside: "text-slate-500 opacity-50",
day_disabled: "text-slate-500 opacity-50 cursor-not-allowed",
day_range_middle: "aria-selected:bg-slate-200",
day_hidden: "invisible",
...classNames,
}}
disabled={{
before: new Date(),
}}
components={{
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
IconRight: () => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@@ -0,0 +1,47 @@
"use client";
import { CalendarIcon } from "lucide-react";
import { format } from "date-fns";
import { cn } from "@formbricks/lib/cn";
import { Popover, PopoverContent, PopoverTrigger } from "./Popover";
import Button from "./Button";
import { Calendar } from "./Calendar";
import { useRef } from "react";
import { SelectSingleEventHandler } from "react-day-picker";
export function DatePicker({
date,
handleDateChange,
}: {
date?: Date;
handleDateChange: (date?: Date) => void;
}) {
let formattedDate = date ? new Date(date) : undefined;
const btnRef = useRef<HTMLButtonElement>(null);
const handleDateSelect: SelectSingleEventHandler = (date) => {
btnRef?.current?.click();
handleDateChange(date);
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant={"minimal"}
className={cn(
"w-[280px] justify-start border border-slate-300 text-left font-normal",
!formattedDate && "text-muted-foreground"
)}
ref={btnRef}>
<CalendarIcon className="mr-2 h-4 w-4" />
{formattedDate ? format(formattedDate, "PPP") : <span>Pick a date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar mode="single" selected={formattedDate} onSelect={handleDateSelect} initialFocus />
</PopoverContent>
</Popover>
);
}

View File

@@ -58,6 +58,8 @@ export {
CommandSeparator,
CommandShortcut,
} from "./components/Command";
export { Calendar } from "./components/Calendar";
export { DatePicker } from "./components/DatePicker";
/* Icons */
export { AngryBirdRageIcon } from "./components/icons/AngryBirdRageIcon";

View File

@@ -46,7 +46,8 @@
"next": "13.4.8",
"react-colorful": "^5.6.1",
"react-confetti": "^6.1.0",
"react-dom": "18.2.0",
"react-day-picker": "^8.8.0",
"react-dom": "^18.2.0",
"react-radio-group": "^3.0.3",
"react-use": "^17.4.0"
}

71
pnpm-lock.yaml generated
View File

@@ -1,4 +1,4 @@
lockfileVersion: '6.0'
lockfileVersion: '6.1'
settings:
autoInstallPeers: true
@@ -19,7 +19,7 @@ importers:
version: 3.12.7
turbo:
specifier: latest
version: 1.10.7
version: 1.10.3
apps/demo:
dependencies:
@@ -349,7 +349,7 @@ importers:
version: 8.8.0(eslint@8.41.0)
eslint-config-turbo:
specifier: latest
version: 1.8.8(eslint@8.41.0)
version: 1.10.3(eslint@8.41.0)
eslint-plugin-react:
specifier: 7.32.2
version: 7.32.2(eslint@8.41.0)
@@ -592,8 +592,11 @@ importers:
react-confetti:
specifier: ^6.1.0
version: 6.1.0(react@18.2.0)
react-day-picker:
specifier: ^8.8.0
version: 8.8.0(date-fns@2.30.0)(react@18.2.0)
react-dom:
specifier: 18.2.0
specifier: ^18.2.0
version: 18.2.0(react@18.2.0)
react-radio-group:
specifier: ^3.0.3
@@ -9536,13 +9539,13 @@ packages:
eslint: 8.41.0
dev: true
/eslint-config-turbo@1.8.8(eslint@8.41.0):
resolution: {integrity: sha512-+yT22sHOT5iC1sbBXfLIdXfbZuiv9bAyOXsxTxFCWelTeFFnANqmuKB3x274CFvf7WRuZ/vYP/VMjzU9xnFnxA==}
/eslint-config-turbo@1.10.3(eslint@8.41.0):
resolution: {integrity: sha512-ggzPfTJfMsMS383oZ4zfTP1zQvyMyiigOQJRUnLt1nqII6SKkTzdKZdwmXRDHU24KFwUfEFtT6c8vnm2VhL0uQ==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
eslint: 8.41.0
eslint-plugin-turbo: 1.8.8(eslint@8.41.0)
eslint-plugin-turbo: 1.10.3(eslint@8.41.0)
dev: true
/eslint-import-resolver-node@0.3.6:
@@ -9861,8 +9864,8 @@ packages:
semver: 6.3.0
string.prototype.matchall: 4.0.8
/eslint-plugin-turbo@1.8.8(eslint@8.41.0):
resolution: {integrity: sha512-zqyTIvveOY4YU5jviDWw9GXHd4RiKmfEgwsjBrV/a965w0PpDwJgEUoSMB/C/dU310Sv9mF3DSdEjxjJLaw6rA==}
/eslint-plugin-turbo@1.10.3(eslint@8.41.0):
resolution: {integrity: sha512-g3Mnnk7el1FqxHfqbE/MayLvCsYjA/vKmAnUj66kV4AlM7p/EZqdt42NMcMSKtDVEm0w+utQkkzWG2Xsa0Pd/g==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
@@ -16714,6 +16717,16 @@ packages:
tween-functions: 1.2.0
dev: false
/react-day-picker@8.8.0(date-fns@2.30.0)(react@18.2.0):
resolution: {integrity: sha512-QIC3uOuyGGbtypbd5QEggsCSqVaPNu8kzUWquZ7JjW9fuWB9yv7WyixKmnaFelTLXFdq7h7zU6n/aBleBqe/dA==}
peerDependencies:
date-fns: ^2.28.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
date-fns: 2.30.0
react: 18.2.0
dev: false
/react-dom@18.2.0(react@18.2.0):
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
peerDependencies:
@@ -19191,65 +19204,65 @@ packages:
dependencies:
safe-buffer: 5.2.1
/turbo-darwin-64@1.10.7:
resolution: {integrity: sha512-N2MNuhwrl6g7vGuz4y3fFG2aR1oCs0UZ5HKl8KSTn/VC2y2YIuLGedQ3OVbo0TfEvygAlF3QGAAKKtOCmGPNKA==}
/turbo-darwin-64@1.10.3:
resolution: {integrity: sha512-IIB9IomJGyD3EdpSscm7Ip1xVWtYb7D0x7oH3vad3gjFcjHJzDz9xZ/iw/qItFEW+wGFcLSRPd+1BNnuLM8AsA==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/turbo-darwin-arm64@1.10.7:
resolution: {integrity: sha512-WbJkvjU+6qkngp7K4EsswOriO3xrNQag7YEGRtfLoDdMTk4O4QTeU6sfg2dKfDsBpTidTvEDwgIYJhYVGzrz9Q==}
/turbo-darwin-arm64@1.10.3:
resolution: {integrity: sha512-SBNmOZU9YEB0eyNIxeeQ+Wi0Ufd+nprEVp41rgUSRXEIpXjsDjyBnKnF+sQQj3+FLb4yyi/yZQckB+55qXWEsw==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/turbo-linux-64@1.10.7:
resolution: {integrity: sha512-x1CF2CDP1pDz/J8/B2T0hnmmOQI2+y11JGIzNP0KtwxDM7rmeg3DDTtDM/9PwGqfPotN9iVGgMiMvBuMFbsLhg==}
/turbo-linux-64@1.10.3:
resolution: {integrity: sha512-kvAisGKE7xHJdyMxZLvg53zvHxjqPK1UVj4757PQqtx9dnjYHSc8epmivE6niPgDHon5YqImzArCjVZJYpIGHQ==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/turbo-linux-arm64@1.10.7:
resolution: {integrity: sha512-JtnBmaBSYbs7peJPkXzXxsRGSGBmBEIb6/kC8RRmyvPAMyqF8wIex0pttsI+9plghREiGPtRWv/lfQEPRlXnNQ==}
/turbo-linux-arm64@1.10.3:
resolution: {integrity: sha512-Qgaqln0IYRgyL0SowJOi+PNxejv1I2xhzXOI+D+z4YHbgSx87ox1IsALYBlK8VRVYY8VCXl+PN12r1ioV09j7A==}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/turbo-windows-64@1.10.7:
resolution: {integrity: sha512-7A/4CByoHdolWS8dg3DPm99owfu1aY/W0V0+KxFd0o2JQMTQtoBgIMSvZesXaWM57z3OLsietFivDLQPuzE75w==}
/turbo-windows-64@1.10.3:
resolution: {integrity: sha512-rbH9wManURNN8mBnN/ZdkpUuTvyVVEMiUwFUX4GVE5qmV15iHtZfDLUSGGCP2UFBazHcpNHG1OJzgc55GFFrUw==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/turbo-windows-arm64@1.10.7:
resolution: {integrity: sha512-D36K/3b6+hqm9IBAymnuVgyePktwQ+F0lSXr2B9JfAdFPBktSqGmp50JNC7pahxhnuCLj0Vdpe9RqfnJw5zATA==}
/turbo-windows-arm64@1.10.3:
resolution: {integrity: sha512-ThlkqxhcGZX39CaTjsHqJnqVe+WImjX13pmjnpChz6q5HHbeRxaJSFzgrHIOt0sUUVx90W/WrNRyoIt/aafniw==}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/turbo@1.10.7:
resolution: {integrity: sha512-xm0MPM28TWx1e6TNC3wokfE5eaDqlfi0G24kmeHupDUZt5Wd0OzHFENEHMPqEaNKJ0I+AMObL6nbSZonZBV2HA==}
/turbo@1.10.3:
resolution: {integrity: sha512-U4gKCWcKgLcCjQd4Pl8KJdfEKumpyWbzRu75A6FCj6Ctea1PIm58W6Ltw1QXKqHrl2pF9e1raAskf/h6dlrPCA==}
hasBin: true
requiresBuild: true
optionalDependencies:
turbo-darwin-64: 1.10.7
turbo-darwin-arm64: 1.10.7
turbo-linux-64: 1.10.7
turbo-linux-arm64: 1.10.7
turbo-windows-64: 1.10.7
turbo-windows-arm64: 1.10.7
turbo-darwin-64: 1.10.3
turbo-darwin-arm64: 1.10.3
turbo-linux-64: 1.10.3
turbo-linux-arm64: 1.10.3
turbo-windows-64: 1.10.3
turbo-windows-arm64: 1.10.3
dev: true
/tween-functions@1.2.0: