diff --git a/cmd/lunogram/main.go b/cmd/lunogram/main.go index 4ce63030..034094ce 100644 --- a/cmd/lunogram/main.go +++ b/cmd/lunogram/main.go @@ -144,7 +144,7 @@ func run() error { logger.Info("initializing cluster") sched := scheduler.NewController(ctx, logger, conf, journeyStore, usersStore, pub) - lead := leader.NewHandler(sched) + lead := leader.NewHandler(sched, managementStore, logger) cons, err := consensus.NewCluster(ctx, logger, conf) if err != nil { return err diff --git a/console/public/sw.js b/console/public/sw.js new file mode 100644 index 00000000..5775fa5d --- /dev/null +++ b/console/public/sw.js @@ -0,0 +1,36 @@ +self.addEventListener('push', function(event) { + if (!event.data) return; + + try { + const data = event.data.json(); + const title = data.title || 'Notification'; + const options = { + body: data.body, + icon: data.icon, + badge: data.badge, + image: data.image, + data: data.data || {} + }; + + event.waitUntil( + self.registration.showNotification(title, options) + ); + } catch (e) { + // If not JSON, show simple text + event.waitUntil( + self.registration.showNotification('Notification', { + body: event.data.text() + }) + ); + } +}); + +self.addEventListener('notificationclick', function(event) { + event.notification.close(); + // We can handle click events here, like opening a specific URL + if (event.notification.data && event.notification.data.url) { + event.waitUntil( + clients.openWindow(event.notification.data.url) + ); + } +}); diff --git a/console/src/api.ts b/console/src/api.ts index 5bef624b..5b3b1d60 100644 --- a/console/src/api.ts +++ b/console/src/api.ts @@ -661,6 +661,26 @@ const api = { }) .then((r) => r.data), }, + + push: { + getVapidPublicKey: async () => + await client + .get<{ public_key: string }>("/admin/push/vapid-public-key") + .then((r) => r.data), + }, + + devices: { + register: async ( + projectId: UUID, + params: { + device_id: string + external_id?: string + anonymous_id?: string + os?: "web" | "ios" | "android" + push_subscription: any + }, + ) => await client.post(`${projectUrl(projectId)}/devices`, params).then((r) => r.data), + }, } export default api diff --git a/console/src/components/schema-fields.tsx b/console/src/components/schema-fields.tsx index 2bb3e6c5..fea42a5a 100644 --- a/console/src/components/schema-fields.tsx +++ b/console/src/components/schema-fields.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react" +import { useEffect, useMemo, useRef } from "react" import type { UseFormReturn } from "react-hook-form" import { snakeToTitle } from "@/utils" @@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input" import { TemplateInput } from "@/components/ui/template-input" import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" +import { Button } from "@/components/ui/button" import { Select, SelectContent, @@ -18,6 +19,135 @@ import { CodeEditor } from "@/components/ui/code-editor" import { KeyValueEditor } from "@/components/ui/key-value-editor" import type { VariableGroup } from "@/views/journey/JourneyVariableContext" +interface StringFieldWithFileProps { + fieldKey: string + item: Schema + value: Record + set: (key: string, v: unknown) => void + required: boolean + fieldTitle: string + variables?: VariableGroup[] +} + +function StringFieldWithFile({ + fieldKey, + item, + value, + set, + required, + fieldTitle, + variables, +}: StringFieldWithFileProps) { + const fileInputRef = useRef(null) + const currentValue = (value[fieldKey] as string) ?? "" + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + + const reader = new FileReader() + reader.onload = () => { + let result = reader.result as string + if (item.requireBase64) { + result = btoa(result) + } + set(fieldKey, result) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + reader.readAsText(file) + } + + const handleBase64Encode = () => { + if (currentValue && !isBase64(currentValue)) { + set(fieldKey, btoa(currentValue)) + } + } + + const isBase64 = (str: string): boolean => { + if (!str) return false + try { + return btoa(atob(str)) === str + } catch { + return false + } + } + + const useTextarea = (item.minLength ?? 0) >= 80 + const useTemplateInput = + !useTextarea && + !item.fileUpload && + variables && + variables.some((g) => g.variables.length > 0) + + return ( +
+ + {item.description && ( +

{item.description}

+ )} +
+
+ {useTextarea ? ( +