diff --git a/apps/forms/app/(api)/submit/[id]/route.ts b/apps/forms/app/(api)/submit/[id]/route.ts index 118da1bf2..c15ce3f60 100644 --- a/apps/forms/app/(api)/submit/[id]/route.ts +++ b/apps/forms/app/(api)/submit/[id]/route.ts @@ -2,6 +2,11 @@ import { SYSTEM_GF_KEY_STARTS_WITH, SYSTEM_GF_FINGERPRINT_VISITORID_KEY, SYSTEM_GF_CUSTOMER_UUID_KEY, + SYSTEM_GF_GEO_CITY_KEY, + SYSTEM_GF_GEO_COUNTRY_KEY, + SYSTEM_GF_GEO_LATITUDE_KEY, + SYSTEM_GF_GEO_LONGITUDE_KEY, + SYSTEM_GF_GEO_REGION_KEY, } from "@/k/system"; import { client, grida_commerce_client } from "@/lib/supabase/server"; import { upsert_customer_with } from "@/services/customer"; @@ -23,6 +28,7 @@ import { GridaCommerceClient } from "@/services/commerce"; import { SubmissionHooks } from "./hooks"; import { Features } from "@/lib/features/scheduling"; import { IpInfo, ipinfo } from "@/lib/ipinfo"; +import { Geo } from "@/types"; const HOST = process.env.HOST || "http://localhost:3000"; @@ -51,10 +57,11 @@ export async function GET( } // #endregion + const data = req.nextUrl.searchParams as any; return submit({ - data: req.nextUrl.searchParams as any, + data: data, form_id, - meta: meta(req), + meta: meta(req, data), }); } @@ -79,10 +86,10 @@ export async function POST( } // #endregion - return submit({ data, form_id, meta: meta(req) }); + return submit({ data, form_id, meta: meta(req, data) }); } -function meta(req: NextRequest) { +function meta(req: NextRequest, data?: FormData) { console.log("ip", { ip: req.ip, "x-real-ip": req.headers.get("x-real-ip"), @@ -102,6 +109,38 @@ function meta(req: NextRequest) { browser: req.headers.get("sec-ch-ua"), }; + // optionally, developer can override the ip and geo via data body. + if (data) { + const __GF_GEO_LATITUDE = data.get(SYSTEM_GF_GEO_LATITUDE_KEY); + const __GF_GEO_LONGITUDE = data.get(SYSTEM_GF_GEO_LONGITUDE_KEY); + const __GF_GEO_REGION = data.get(SYSTEM_GF_GEO_REGION_KEY); + const __GF_GEO_COUNTRY = data.get(SYSTEM_GF_GEO_COUNTRY_KEY); + const __GF_GEO_CITY = data.get(SYSTEM_GF_GEO_CITY_KEY); + + if ( + __GF_GEO_LATITUDE || + __GF_GEO_LONGITUDE || + __GF_GEO_REGION || + __GF_GEO_COUNTRY || + __GF_GEO_CITY + ) { + // all or neither the lat and long should be present + assert( + (__GF_GEO_LATITUDE && __GF_GEO_LONGITUDE) || + (!__GF_GEO_LATITUDE && !__GF_GEO_LONGITUDE), + "Both or neither latitude and longitude should be present" + ); + + meta.geo = { + latitude: __GF_GEO_LATITUDE ? String(__GF_GEO_LATITUDE) : undefined, + longitude: __GF_GEO_LONGITUDE ? String(__GF_GEO_LONGITUDE) : undefined, + region: __GF_GEO_REGION ? String(__GF_GEO_REGION) : undefined, + country: __GF_GEO_COUNTRY ? String(__GF_GEO_COUNTRY) : undefined, + city: __GF_GEO_CITY ? String(__GF_GEO_CITY) : undefined, + }; + } + } + return meta; } @@ -675,11 +714,3 @@ function ipinfogeo(ipinfo: IpInfo): Geo | null { region: ipinfo.region, }; } - -interface Geo { - city?: string | undefined; - country?: string | undefined; - region?: string | undefined; - latitude?: string | undefined; - longitude?: string | undefined; -} diff --git a/apps/forms/app/(d)/d/[id]/data/analytics/page.tsx b/apps/forms/app/(d)/d/[id]/data/analytics/page.tsx index 23cb6456c..15d30e8e7 100644 --- a/apps/forms/app/(d)/d/[id]/data/analytics/page.tsx +++ b/apps/forms/app/(d)/d/[id]/data/analytics/page.tsx @@ -1,143 +1,5 @@ -"use client"; - -import { Customers, Responses } from "@/scaffolds/analytics/stats"; -import { FormResponsesProvider } from "@/scaffolds/editor"; -import { MapGL } from "@/theme/templates/formstart/default/mapgl"; -import React, { useEffect, useState } from "react"; -import { MapProvider, useMap } from "react-map-gl"; -import { useDarkMode } from "usehooks-ts"; -import { useWindowSize } from "@uidotdev/usehooks"; -import type { CircleLayer } from "react-map-gl"; -import { Source, Layer } from "react-map-gl"; -import type { FeatureCollection } from "geojson"; - -const geojson: FeatureCollection = { - type: "FeatureCollection", - features: [ - { - type: "Feature", - geometry: { type: "Point", coordinates: [-122.4, 37.8] }, - properties: { name: "San Francisco" }, - }, - ], -}; - -const layerstyles: { light: CircleLayer; dark: CircleLayer } = { - light: { - id: "point", - type: "circle", - paint: { - "circle-radius": 10, - "circle-opacity-transition": { duration: 1000 }, - "circle-opacity": 0.6, - "circle-color": "black", - "circle-stroke-width": 2, - "circle-stroke-color": "white", - "circle-stroke-opacity": 0.8, - }, - }, - dark: { - id: "point", - type: "circle", - paint: { - "circle-radius": 10, - "circle-opacity-transition": { duration: 1000 }, - "circle-opacity": 0.9, - "circle-color": "white", - "circle-stroke-width": 2, - "circle-stroke-color": "black", - "circle-stroke-opacity": 0.8, - }, - }, -}; - -const DisableSwipeBack = ({ children }: React.PropsWithChildren<{}>) => { - useEffect(() => { - document.body.style.overscrollBehaviorX = "none"; - - return () => { - document.body.style.overscrollBehaviorX = ""; - }; - }, []); - - return <>{children}; -}; +import LiveWorldAnalytics from "@/scaffolds/analytics/world/live-world-analytics"; export default function DataAnalyticsPage() { - return ( - - - - - - - - ); -} - -function View() { - const { isDarkMode } = useDarkMode(); - const { map } = useMap(); - const size = useWindowSize(); - - useEffect(() => { - console.log("map", map); - setTimeout(() => { - map?.flyTo({ - padding: { - top: 0, - bottom: 0, - left: (size.width || 1000) * 0.4, - right: 0, - }, - center: [37.6173 + Math.random() * 0.1, 55.7558 + Math.random() * 0.1], - zoom: 12, - }); - }, 1000); - }, [map]); - - return ( -
-
-
- - - - - -
-
-
- - -
-
- ); + return ; } diff --git a/apps/forms/app/(d)/d/[id]/data/simulator/page.tsx b/apps/forms/app/(d)/d/[id]/data/simulator/page.tsx new file mode 100644 index 000000000..509a68428 --- /dev/null +++ b/apps/forms/app/(d)/d/[id]/data/simulator/page.tsx @@ -0,0 +1,487 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + SimulationPlan, + Simulator, + SimulatorSubmission, +} from "@/lib/simulator"; +import { format } from "date-fns"; +import { useEffect, useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import { useStopwatch, useTimer } from "react-timer-hook"; + +type SimulatorStatus = "none" | "idle" | "running" | "paused"; + +const START_COUNTDOWN = 5 * 1000; + +export default function SimulatorPage({ + params, +}: { + params: { + id: string; + }; +}) { + const form_id = params.id; + const [status, setStatus] = useState("none"); + const [startsAt, setStartsAt] = useState(null); + const [plan, setPlan] = useState(null); + + return ( +
+ + + { + setPlan(plan); + setStatus("idle"); + setStartsAt(new Date(Date.now() + START_COUNTDOWN)); + setTimeout(() => { + const width = 1280; + const height = 720; + const left = screen.width - width - 50; + const top = screen.height - height - 100; + + window.open( + "./analytics", + "_blank", + `width=${width},height=${height},left=${left},top=${top}` + ); + }, 200); + }} + /> + + + {status === "idle" && ( + { + if (status === "idle") { + setStatus("running"); + toast.success("Simulation started"); + } + }} + /> + )} + {status === "running" && } +
+ ); +} + +function TaskHandler({ + form_id, + plan, +}: { + form_id: string; + plan: SimulationPlan; +}) { + const simulator = useMemo( + () => new Simulator(form_id, plan), + [form_id, plan] + ); + + const [isEnded, setIsEnded] = useState(false); + const [responses, setResponses] = useState([]); + + useEffect(() => { + const handleNewResponse = (id: string, payload: SimulatorSubmission) => { + setResponses((prev) => { + const index = prev.findIndex((r) => r._id === id); + if (index >= 0) { + const copy = [...prev]; + copy[index] = payload; + return copy; + } else { + return [...prev, payload]; + } + }); + }; + + simulator.onResponse(handleNewResponse); + + simulator.onEnd(() => { + setIsEnded(true); + toast.success("Simulation ended", { + duration: 10000, + }); + }); + + simulator.start(); + + return () => { + simulator.pause(); + simulator.offResponse(handleNewResponse); + }; + }, [simulator]); + + return ( +
+
+ { + if (!running) { + simulator.pause(); + } else { + simulator.resume(); + } + }} + /> + + +

Responses

+ + Responses from the simulation + +
+ +
+ + Total: {responses.length} + +
+
+
+
+
+ + + + Status + ID + Resolved At + + + + {responses.map((response, index) => ( + + + + + + + {response._id} + + + + {response.resolvedAt + ? format(response.resolvedAt, "HH:mm:ss.SSS") + : ""} + + + ))} + +
+
+
+ ); +} + +const status_colors = { + 0: "gray", + 200: "green", + 400: "yellow", + 500: "red", +}; + +const status_texts = { + 0: "idle", + 200: "ok", + 400: "bad", + 500: "error", +}; + +function StatusBadge({ status }: { status?: number }) { + return ( + +
+ {(status_texts as any)[status ?? 0]} + + ); +} + +function SimulationPlanner({ + onStartQueued, +}: { + onStartQueued?: (plan: SimulationPlan) => void; +}) { + const [n, setN] = useState(50); + const [maxq, setMaxQ] = useState(5); + const [delay, setDelay] = useState(800); + const [randomness, setRandomness] = useState(0.5); + + return ( +
+
+ + Beta + +

New Simulation

+ + This is a simulator to simulate the form submission. This is useful + for testing purposes.{" "} + Note: Closing this page will stop the simulation. + +
+
+
+ + setN(Number(e.target.value))} + /> +
+
+ + setMaxQ(v[0])} + /> + {maxq} +
+
+ + setDelay(v[0])} + /> + {delay}ms +
+
+ + setRandomness(v[0])} + /> + {randomness} +
+ + + + + + + About This Simulation + +

+ + Note: Starting simulation WILL INSERT{" "} + actual data. + +
+

    +
  • + Recommended to run simulations on newly created forms, only. +
  • +
  • Existing data will not be affected.
  • +
  • This will create new customer entries
  • +
  • Bots will act as humans and submit the form.
  • +
  • Gloabl attributes such as Inventory will be affected
  • +
  • + You will have to clean up the data manually after the + simulation +
  • +
+ This is only recommended for testing purposes and before going + production. (You will be charged for the simulation, as it uses + real data.) +

+
+
+
+ + + + + + + + +
+
+
+ ); +} + +function WillStartSoon({ at, onExpire }: { at: Date; onExpire?: () => void }) { + const { + seconds, + minutes, + hours, + days, + isRunning, + start, + pause, + resume, + restart, + } = useTimer({ + // +10 seconds + expiryTimestamp: at, + onExpire, + }); + + return ( + + +

Simulation will start in

+
+ +
+
+ {days}d {hours}h {minutes}m {seconds}s +
+
+ {isRunning ? ( + + ) : ( + + )} +
+
+
+
+ ); +} + +function StartedAndCounting({ + isEnded, + onRunningChange, +}: { + isEnded?: boolean; + onRunningChange?: (running: boolean) => void; +}) { + const { seconds, minutes, hours, days, isRunning, start, pause } = + useStopwatch({ + autoStart: true, + }); + + useEffect(() => { + onRunningChange?.(isRunning); + }, [isRunning, onRunningChange]); + + useEffect(() => { + if (isEnded) { + pause(); + } + }, [isEnded, pause]); + + return ( + + + {isEnded ?

Simulation Ended

:

Simulation is Running

} +
+ +
+
+ {days}d {hours}h {minutes}m {seconds}s +
+
+ {isEnded ? ( + <> + ) : ( + <> + {isRunning ? ( + + ) : ( + + )} + + )} +
+
+
+
+ ); +} diff --git a/apps/forms/app/(dev)/layout.tsx b/apps/forms/app/(dev)/layout.tsx index 7a86bb027..fd32a3b19 100644 --- a/apps/forms/app/(dev)/layout.tsx +++ b/apps/forms/app/(dev)/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import { ThemeProvider } from "@/components/theme-provider"; -import { Toaster } from "react-hot-toast"; +import { ToasterWithMax } from "@/components/toaster"; import "../editor.css"; @@ -19,7 +19,7 @@ export default function RootLayout({ return ( - + @@ -31,36 +35,36 @@ export function PreviewButton({ form_id }: { form_id: string }) { Preview - - + + - - - - - - - Built in Agent - - - router.push(custom_agent_setting_url)} - > - + + + + + + Built in Agent + + + + + Configure Agent - - - - + + + + + + Simulator + + + +
); } diff --git a/apps/forms/components/toaster/index.tsx b/apps/forms/components/toaster/index.tsx index 38d30ca3b..2d4944099 100644 --- a/apps/forms/components/toaster/index.tsx +++ b/apps/forms/components/toaster/index.tsx @@ -22,5 +22,13 @@ export function ToasterWithMax({ }) { useMaxToasts(max); - return ; + return ( + + ); } diff --git a/apps/forms/components/ui/scroll-area.tsx b/apps/forms/components/ui/scroll-area.tsx new file mode 100644 index 000000000..2fbb4c149 --- /dev/null +++ b/apps/forms/components/ui/scroll-area.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/utils" + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "vertical", ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } diff --git a/apps/forms/components/utility/disable-swipe-back.tsx b/apps/forms/components/utility/disable-swipe-back.tsx new file mode 100644 index 000000000..6ce7e419f --- /dev/null +++ b/apps/forms/components/utility/disable-swipe-back.tsx @@ -0,0 +1,13 @@ +import React, { useEffect } from "react"; + +export const DisableSwipeBack = ({ children }: React.PropsWithChildren<{}>) => { + useEffect(() => { + document.body.style.overscrollBehaviorX = "none"; + + return () => { + document.body.style.overscrollBehaviorX = ""; + }; + }, []); + + return <>{children}; +}; diff --git a/apps/forms/k/system.ts b/apps/forms/k/system.ts index e6c82a585..49d0d61b4 100644 --- a/apps/forms/k/system.ts +++ b/apps/forms/k/system.ts @@ -1,5 +1,13 @@ export const SYSTEM_GF_KEY_STARTS_WITH = "__gf_"; +// fingerprint export const SYSTEM_GF_FINGERPRINT_VISITORID_KEY = "__gf_fp_fingerprintjs_visitorid"; +// customer export const SYSTEM_GF_CUSTOMER_UUID_KEY = "__gf_customer_uuid"; export const SYSTEM_GF_CUSTOMER_EMAIL_KEY = "__gf_customer_email"; +// geo +export const SYSTEM_GF_GEO_LATITUDE_KEY = "__gf_geo_latitude"; +export const SYSTEM_GF_GEO_LONGITUDE_KEY = "__gf_geo_longitude"; +export const SYSTEM_GF_GEO_CITY_KEY = "__gf_geo_city"; +export const SYSTEM_GF_GEO_REGION_KEY = "__gf_geo_region"; +export const SYSTEM_GF_GEO_COUNTRY_KEY = "__gf_geo_country"; diff --git a/apps/forms/lib/simulator/index.ts b/apps/forms/lib/simulator/index.ts new file mode 100644 index 000000000..241107541 --- /dev/null +++ b/apps/forms/lib/simulator/index.ts @@ -0,0 +1,158 @@ +import { + SYSTEM_GF_CUSTOMER_UUID_KEY, + SYSTEM_GF_GEO_CITY_KEY, + SYSTEM_GF_GEO_COUNTRY_KEY, + SYSTEM_GF_GEO_LATITUDE_KEY, + SYSTEM_GF_GEO_LONGITUDE_KEY, + SYSTEM_GF_GEO_REGION_KEY, +} from "@/k/system"; +import { faker } from "@faker-js/faker"; +import { nanoid } from "nanoid"; + +export interface SimulationPlan { + n: number; // Total number of submissions + delaybetween: number; // Delay between submissions in ms + queue: number; // Base number of concurrent submissions per batch + randomness: number; // Random coefficient for submission timing +} + +type ResponseCallback = (id: string, response: SimulatorSubmission) => void; +type EndCallback = () => void; + +export interface SimulatorSubmission { + _id: string; + resolvedAt?: Date; + status?: 200 | 400 | 500 | (number | {}); + data?: T; +} + +export class Simulator { + readonly responses: SimulatorSubmission[] = []; + private isPaused: boolean = false; + private isEnded: boolean = false; + private activeSubmissions: number = 0; + private responseCallbacks: ResponseCallback[] = []; + private endCallback?: EndCallback; + private totalSubmitted: number = 0; + + constructor( + readonly form_id: string, + readonly plan: SimulationPlan, + readonly dryrun: boolean = false + ) {} + + async start() { + while (this.totalSubmitted < this.plan.n && !this.isPaused) { + const randomizedQueue = Math.floor( + this.plan.queue * (1 + (Math.random() - 0.5) * this.plan.randomness) + ); + const batchCount = Math.min( + randomizedQueue, + this.plan.n - this.totalSubmitted + ); + await this.submitBatch(batchCount); + } + + // end + if (this.isEnded) return; + if (this.totalSubmitted >= this.plan.n) { + this.isEnded = true; + this.endCallback?.(); + } + } + + pause() { + this.isPaused = true; + } + + resume() { + if (this.isPaused) { + this.isPaused = false; + this.start(); + } + } + + onResponse(callback: ResponseCallback) { + this.responseCallbacks.push(callback); + } + + offResponse(callback: ResponseCallback) { + this.responseCallbacks = this.responseCallbacks.filter( + (cb) => cb !== callback + ); + } + + onEnd(callback: EndCallback) { + this.endCallback = callback; + } + + private async submitBatch(batchCount: number) { + const promises = []; + for (let i = 0; i < batchCount; i++) { + if (this.isPaused) break; + promises.push(this.submitForm()); + const delay = + this.plan.delaybetween * + (1 + (Math.random() - 0.5) * this.plan.randomness); + await this.sleep(delay); + } + await Promise.all(promises); + this.totalSubmitted += batchCount; + } + + private async submitForm() { + const data = this.generateFormData(); + try { + const _id = nanoid(); + const request: SimulatorSubmission = { + _id, + status: undefined, + data: data, + }; + this.responses.push(request); + // Notify initially + this.responseCallbacks.forEach((cb) => cb(_id, request)); + if (this.dryrun) { + return; + } + const response = await submit(this.form_id, data); + request.status = response.status; + request.resolvedAt = new Date(); + // Notify after response + this.responseCallbacks.forEach((cb) => cb(_id, request)); + } catch (error) { + console.error("Form submission failed", error); + } + } + + private generateFormData() { + // Generate random form data + return { + [SYSTEM_GF_CUSTOMER_UUID_KEY]: faker.string.uuid(), + [SYSTEM_GF_GEO_CITY_KEY]: faker.location.city(), + [SYSTEM_GF_GEO_LATITUDE_KEY]: faker.location.latitude(), + [SYSTEM_GF_GEO_LONGITUDE_KEY]: faker.location.longitude(), + [SYSTEM_GF_GEO_REGION_KEY]: faker.location.state(), + [SYSTEM_GF_GEO_COUNTRY_KEY]: faker.location.country(), + + // TODO: use faker to generate random data based on form schema + // Add your form data structure here + }; + } + + private sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} + +async function submit(form_id: string, data: any) { + const formdata = new FormData(); + for (const key in data) { + formdata.append(key, data[key]); + } + + return fetch(`/submit/${form_id}`, { + method: "POST", + body: formdata, + }); +} diff --git a/apps/forms/package.json b/apps/forms/package.json index d696689f9..61c0639a0 100644 --- a/apps/forms/package.json +++ b/apps/forms/package.json @@ -36,6 +36,7 @@ "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-progress": "^0.0.13", "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-scroll-area": "^0.0.16", "@radix-ui/react-select": "^1.2.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slider": "^0.0.11", @@ -99,6 +100,7 @@ "react-pdf": "^7.7.1", "react-player": "^2.14.1", "react-textarea-autosize": "^8.5.3", + "react-timer-hook": "^3.0.7", "resend": "^3.2.0", "signature_pad": "^4.2.0", "swr": "^2.2.5", diff --git a/apps/forms/scaffolds/analytics/charts/serialize.ts b/apps/forms/scaffolds/analytics/charts/serialize.ts new file mode 100644 index 000000000..1304c4992 --- /dev/null +++ b/apps/forms/scaffolds/analytics/charts/serialize.ts @@ -0,0 +1,47 @@ +export function serialize>( + data: Array, + { + from, + to, + dateKey, + intervalMs, + }: { + from: Date; + to: Date; + dateKey: keyof T; + intervalMs: number; + } +) { + // Step 1: Create a map for the new data with the provided dates range + const dateMap: Record = {}; + let currentDate = new Date(from); + while (currentDate <= to) { + const dateString = new Date( + Math.floor(currentDate.getTime() / intervalMs) * intervalMs + ).toISOString(); + dateMap[dateString] = 0; + currentDate = new Date(currentDate.getTime() + intervalMs); // Move to the next interval + } + + // Step 2: Populate the map with actual data + data.forEach((item) => { + const dateValue = item[dateKey]; + if (typeof dateValue === "string" || (dateValue as any) instanceof Date) { + const date = new Date(dateValue).toISOString(); + const roundedDate = new Date( + Math.floor(new Date(date).getTime() / intervalMs) * intervalMs + ).toISOString(); + if (dateMap[roundedDate] !== undefined) { + dateMap[roundedDate]++; + } + } + }); + + // Step 3: Format the data for output + const formattedData = Object.entries(dateMap).map(([date, count]) => ({ + date: new Date(date), + count, + })); + + return formattedData; +} diff --git a/apps/forms/scaffolds/analytics/charts/basic-line-chart.tsx b/apps/forms/scaffolds/analytics/charts/timeseries.tsx similarity index 68% rename from apps/forms/scaffolds/analytics/charts/basic-line-chart.tsx rename to apps/forms/scaffolds/analytics/charts/timeseries.tsx index f47616720..fa225bf1c 100644 --- a/apps/forms/scaffolds/analytics/charts/basic-line-chart.tsx +++ b/apps/forms/scaffolds/analytics/charts/timeseries.tsx @@ -6,30 +6,38 @@ import { XYChart, AnimatedAxis, AnimatedLineSeries, + AnimatedBarSeries, Tooltip, DataProvider, } from "@visx/xychart"; +import { useDarkMode } from "usehooks-ts"; -interface LineChartData { +interface TimeSeriesChartData { date: Date; count: number; } -interface LineChartProps { - data: LineChartData[]; +interface TimeSeriesChartProps { + data: TimeSeriesChartData[]; + chartType: "line" | "bar"; height?: number; margin?: { top: number; right: number; bottom: number; left: number }; + datefmt?: (date: Date) => string; } -const LineChart: React.FC = ({ +const TimeSeriesChart: React.FC = ({ data, + chartType, + datefmt = (date) => date.toLocaleDateString(), margin = { top: 16, right: 16, bottom: 40, left: 40 }, }) => { + const { isDarkMode } = useDarkMode(); + if (data.length === 0) return null; const accessors = { - xAccessor: (d: LineChartData) => d.date, - yAccessor: (d: LineChartData) => d.count, + xAccessor: (d: TimeSeriesChartData) => d.date, + yAccessor: (d: TimeSeriesChartData) => d.count, }; return ( @@ -37,13 +45,22 @@ const LineChart: React.FC = ({ {({ width, height }) => ( - + {chartType === "line" ? ( + + ) : ( + (isDarkMode ? "white" : "black")} + data={data} + {...accessors} + /> + )} = ({ })} /> = ({
- {accessors - .xAccessor(tooltipData?.nearestDatum?.datum as any) - .toLocaleDateString()} + {datefmt( + accessors.xAccessor( + tooltipData?.nearestDatum?.datum as any + ) + )} {" "} {accessors.yAccessor( @@ -111,4 +131,4 @@ const LineChart: React.FC = ({ ); }; -export default LineChart; +export default TimeSeriesChart; diff --git a/apps/forms/scaffolds/analytics/stats/index.tsx b/apps/forms/scaffolds/analytics/stats/index.tsx index 4403b52e4..14fe80ac9 100644 --- a/apps/forms/scaffolds/analytics/stats/index.tsx +++ b/apps/forms/scaffolds/analytics/stats/index.tsx @@ -5,7 +5,7 @@ import { createClientFormsClient, createClientWorkspaceClient, } from "@/lib/supabase/client"; -import LineChart from "../charts/basic-line-chart"; +import TimeSeriesChart from "../charts/timeseries"; import { GraphSkeleton, NumberSkeleton } from "../charts/skeleton"; import { SupabaseClient } from "@supabase/supabase-js"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; @@ -17,6 +17,9 @@ import { SelectContent, SelectItem, } from "@/components/ui/select"; +import { serialize } from "../charts/serialize"; + +const DAY_MS = 24 * 60 * 60 * 1000; interface LineChartData { date: Date; @@ -70,7 +73,6 @@ export function ProjectStats({ project_id }: { project_id: number }) { return (
-

Your Overview

); } -function serialize>( - data: Array, - { - from, - to, - dateKey, - }: { - from: Date; - to: Date; - dateKey: keyof T; - } -) { - // Step 1: Create a map for the new data with the provided dates range - const dateMap: Record = {}; - let currentDate = new Date(from); - while (currentDate <= to) { - const dateString = currentDate.toLocaleDateString(); - dateMap[dateString] = 0; - currentDate.setDate(currentDate.getDate() + 1); // Move to the next day - } - - // Step 2: Populate the map with actual data - data.forEach((item) => { - const dateValue = item[dateKey]; - if (typeof dateValue === "string" || (dateValue as any) instanceof Date) { - const date = new Date(dateValue).toLocaleDateString(); - if (dateMap[date] !== undefined) { - dateMap[date]++; - } - } - }); - - // Step 3: Format the data for output - const formattedData = Object.entries(dateMap).map(([date, count]) => ({ - date: new Date(date), - count, - })); - - return formattedData; -} -function fmtnum(num: number) { +export function fmtnum(num: number) { return num.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, "$1,"); } @@ -225,6 +187,7 @@ export function Customers({ from, to, dateKey: "created_at", + intervalMs: DAY_MS, }) ); } @@ -254,7 +217,7 @@ export function Customers({
) : ( - + )} @@ -296,6 +259,7 @@ export function Responses({ from, to, dateKey: "created_at", + intervalMs: DAY_MS, }) ); } @@ -325,7 +289,7 @@ export function Responses({
) : ( - + )} diff --git a/apps/forms/scaffolds/analytics/world/live-world-analytics.tsx b/apps/forms/scaffolds/analytics/world/live-world-analytics.tsx new file mode 100644 index 000000000..091c96a44 --- /dev/null +++ b/apps/forms/scaffolds/analytics/world/live-world-analytics.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { fmtnum } from "@/scaffolds/analytics/stats"; +import { FormResponsesProvider, useEditorState } from "@/scaffolds/editor"; +import { MapGL } from "@/theme/templates/formstart/default/mapgl"; +import React, { useEffect, useMemo, useState } from "react"; +import { MapProvider, useMap } from "react-map-gl"; +import { useDarkMode } from "usehooks-ts"; +import { useWindowSize } from "@uidotdev/usehooks"; +import type { CircleLayer, MapRef } from "react-map-gl"; +import { Source, Layer } from "react-map-gl"; +import type { FeatureCollection } from "geojson"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import TimeSeriesChart from "@/scaffolds/analytics/charts/timeseries"; +import { useUxInitialTransform, useUxMapFocus } from "./use-ux-map-focus"; +import { serialize } from "../charts/serialize"; +import { format } from "date-fns"; + +const layerstyles: { light: CircleLayer; dark: CircleLayer } = { + light: { + id: "point", + type: "circle", + paint: { + "circle-radius": 10, + "circle-opacity-transition": { duration: 1000 }, + "circle-opacity": 0.6, + "circle-color": "black", + "circle-stroke-width": 2, + "circle-stroke-color": "white", + "circle-stroke-opacity": 0.8, + }, + }, + dark: { + id: "point", + type: "circle", + paint: { + "circle-radius": 10, + "circle-opacity-transition": { duration: 1000 }, + "circle-opacity": 0.9, + "circle-color": "white", + "circle-stroke-width": 2, + "circle-stroke-color": "black", + "circle-stroke-opacity": 0.8, + }, + }, +}; + +const DisableSwipeBack = ({ children }: React.PropsWithChildren<{}>) => { + useEffect(() => { + document.body.style.overscrollBehaviorX = "none"; + + return () => { + document.body.style.overscrollBehaviorX = ""; + }; + }, []); + + return <>{children}; +}; + +export default function LiveWorldAnalytics() { + return ( + + + + + + + + ); +} + +const RECENT_N = 5; + +interface Response { + id: string; + at: Date; + latitude: number; + longitude: number; +} + +function View() { + const { isDarkMode } = useDarkMode(); + const { map } = useMap(); + const size = useWindowSize(); + const [recent, setRecent] = useState([]); + const mapPadding = useMemo( + () => ({ + top: 0, + bottom: 0, + left: (size.width || 1000) * 0.4, + right: 0, + }), + [size.width] + ); + + const [state] = useEditorState(); + useUxInitialTransform(map, size); + const debounceFlyTo = useUxMapFocus(map, mapPadding, 1000); + + const geojson: FeatureCollection = useMemo( + () => ({ + type: "FeatureCollection", + features: recent.map((r) => ({ + type: "Feature", + geometry: { type: "Point", coordinates: [r.longitude, r.latitude] }, + properties: { id: r.id }, + })), + }), + [recent] + ); + + useEffect(() => { + if (state.responses && state.responses.length > 0) { + const sorted = state.responses + .slice() + .sort((a, b) => a.local_index - b.local_index); + const recent = sorted.slice(-RECENT_N).map((r) => ({ + id: r.id, + at: new Date(r.created_at), + latitude: Number(r.geo?.latitude) || 0, + longitude: Number(r.geo?.longitude) || 0, + })); + const last = recent[recent.length - 1]; + setRecent(recent); + + debounceFlyTo(last.longitude, last.latitude); + } + }, [state.responses, debounceFlyTo]); + + const chartdata = useMemo(() => { + return serialize(state.responses || [], { + dateKey: "created_at", + // last 15 minutes + from: new Date(new Date().getTime() - 15 * 60 * 1000), + to: new Date(), + intervalMs: 15 * 1000, // 15 seconds + }); + }, [state.responses]); + + return ( +
+
+
+ + + + + +
+
+
+
+ +
+
+
+ ); +} + +function Responses({ data }: { data: { count: number; date: Date }[] }) { + return ( + + +
+

Responses

+
+ Responses in Last 15 Minutes +
+
+
+ + {fmtnum(data.reduce((sum, item) => sum + item.count, 0))} + +
+
+ + format(date, "HH:mm:ss.SSS")} + /> + +
+ ); +} diff --git a/apps/forms/scaffolds/analytics/world/use-ux-map-focus.ts b/apps/forms/scaffolds/analytics/world/use-ux-map-focus.ts new file mode 100644 index 000000000..318b57e1d --- /dev/null +++ b/apps/forms/scaffolds/analytics/world/use-ux-map-focus.ts @@ -0,0 +1,104 @@ +import { useEffect, useCallback, useRef } from "react"; +import type { MapRef } from "react-map-gl"; +import type { PaddingOptions } from "mapbox-gl"; + +function useThrottledWithInitialTrigger void>( + callback: T, + interval: number +): (...args: Parameters) => void { + const callbackRef = useRef(callback); + const timeoutRef = useRef | null>(null); + const lastCallTimeRef = useRef(null); + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + const throttledCallback = useCallback( + (...args: Parameters) => { + const now = Date.now(); + + if ( + lastCallTimeRef.current === null || + now - lastCallTimeRef.current >= interval + ) { + callbackRef.current(...args); + lastCallTimeRef.current = now; + } else { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout( + () => { + callbackRef.current(...args); + lastCallTimeRef.current = Date.now(); + }, + interval - (now - lastCallTimeRef.current) + ); + } + }, + [interval] + ); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return throttledCallback; +} + +export function useUxMapFocus( + map: MapRef | undefined, + mapPadding: PaddingOptions, + interval: number = 300 +) { + const throttledFlyTo = useThrottledWithInitialTrigger( + (longitude: number, latitude: number) => { + if (map) { + map.flyTo({ + padding: mapPadding, + center: [longitude, latitude], + zoom: 3, + }); + } + }, + interval + ); + + return throttledFlyTo; +} + +export function useUxInitialTransform( + map?: MapRef, + size?: { width?: number | null } +) { + const isInitiallyTransformed = useRef(false); + const width = size?.width; + useEffect(() => { + if (map && !isInitiallyTransformed.current) { + map.setCenter([180, 90]); + map.setZoom(0); + map.setBearing(30); + map.setPitch(10); + map.setPadding({ + left: (width || 1000) / 2, + right: 0, + top: 0, + bottom: 0, + }); + setTimeout(() => { + map.flyTo({ + zoom: 3, + center: [0, 0], + bearing: 0, + pitch: 0, + }); + }, 100); + isInitiallyTransformed.current = true; + } + }, [map, width]); +} diff --git a/apps/forms/scaffolds/editor/editor.tsx b/apps/forms/scaffolds/editor/editor.tsx index fa4ea0b53..4cf5ea269 100644 --- a/apps/forms/scaffolds/editor/editor.tsx +++ b/apps/forms/scaffolds/editor/editor.tsx @@ -85,7 +85,7 @@ export function InitialResponsesProvider({ (data) => { dispatch({ type: "editor/response/feed", - data: data, + data: data as any, reset: true, }); } @@ -110,7 +110,7 @@ export function InitialResponsesProvider({ (data) => { dispatch({ type: "editor/response/feed", - data: data, + data: data as any, reset: true, }); } @@ -181,7 +181,7 @@ export function FormResponsesProvider({ console.log("new response", data); dispatch({ type: "editor/response/feed", - data: [data], + data: [data as any], }); }); diff --git a/apps/forms/scaffolds/grid-editor/index.tsx b/apps/forms/scaffolds/grid-editor/index.tsx index bf3125408..cfd47e540 100644 --- a/apps/forms/scaffolds/grid-editor/index.tsx +++ b/apps/forms/scaffolds/grid-editor/index.tsx @@ -16,8 +16,9 @@ import toast from "react-hot-toast"; import { useEditorState } from "../editor"; import Link from "next/link"; import { + CommitIcon, DownloadIcon, - OpenInNewWindowIcon, + PieChartIcon, TrashIcon, } from "@radix-ui/react-icons"; import { fmt_local_index } from "@/utils/fmt"; @@ -147,23 +148,15 @@ export function GridEditor() { return (
-
- {/* - - Analytics - - - */} - {has_selected_responses && ( - - {txt_n_responses(selected_responses.size)} selected - - )} - {has_selected_responses ? ( - <> +
+ +
+
+
+ + + Realtime + + + + + + Simulator + + + +
; } + +export interface Geo { + city?: string | undefined; + country?: string | undefined; + region?: string | undefined; + latitude?: string | undefined; + longitude?: string | undefined; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68d09d863..1e6278d0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: '@radix-ui/react-radio-group': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-scroll-area': + specifier: ^0.0.16 + version: 0.0.16(react@18.3.1) '@radix-ui/react-select': specifier: ^1.2.0 version: 1.2.2(@types/react-dom@18.2.19)(@types/react@18.2.58)(react-dom@18.3.1)(react@18.3.1) @@ -457,6 +460,9 @@ importers: react-textarea-autosize: specifier: ^8.5.3 version: 8.5.3(@types/react@18.2.58)(react@18.3.1) + react-timer-hook: + specifier: ^3.0.7 + version: 3.0.7(react@18.3.1) resend: specifier: ^3.2.0 version: 3.2.0 @@ -21261,7 +21267,7 @@ packages: /broadcast-channel@4.4.0: resolution: {integrity: sha512-25UGbuDUqVnMVy+wEFT2eSDWBVncpxiHnGmFqb1r4lYNuC7YUN7gcsXQlTUlaHsP51leOQ007K+d44aN0qrGtg==} dependencies: - '@babel/runtime': 7.16.3 + '@babel/runtime': 7.24.5 detect-node: 2.1.0 microseconds: 0.2.0 nano-time: 1.0.0 @@ -36302,6 +36308,14 @@ packages: - '@types/react' dev: false + /react-timer-hook@3.0.7(react@18.3.1): + resolution: {integrity: sha512-ATpNcU+PQRxxfNBPVqce2+REtjGAlwmfoNQfcEBMZFxPj0r3GYdKhyPHdStvqrejejEi0QvqaJZjy2lBlFvAsA==} + peerDependencies: + react: ^18.2.0 + dependencies: + react: 18.3.1 + dev: false + /react-transition-group@4.4.5(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} peerDependencies: @@ -40410,7 +40424,7 @@ packages: /unload@2.3.1: resolution: {integrity: sha512-MUZEiDqvAN9AIDRbbBnVYVvfcR6DrjCqeU2YQMmliFZl9uaBUjTkhuDQkBiyAy8ad5bx1TXVbqZ3gg7namsWjA==} dependencies: - '@babel/runtime': 7.16.3 + '@babel/runtime': 7.24.5 detect-node: 2.1.0 dev: false