diff --git a/apps/backgrounds/package.json b/apps/backgrounds/package.json index f77fb2ac57..c935da75c5 100644 --- a/apps/backgrounds/package.json +++ b/apps/backgrounds/package.json @@ -11,12 +11,12 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@next/third-parties": "16.1.2", + "@next/third-parties": "16.1.3", "@react-three/drei": "^10.0.7", "@react-three/fiber": "9.1.2", "clsx": "^2.1.1", "motion": "^12.11.0", - "next": "16.1.2", + "next": "16.1.3", "react": "19.2.3", "react-dom": "19.2.3", "shadergradient": "^1.2.14", @@ -31,7 +31,7 @@ "@types/react-dom": "^19", "@types/three": "^0.170.0", "eslint": "^9", - "eslint-config-next": "16.1.2", + "eslint-config-next": "16.1.3", "tailwindcss": "^4", "typescript": "^5" } diff --git a/apps/viewer/package.json b/apps/viewer/package.json index 58c6ff6a30..11373b5a68 100644 --- a/apps/viewer/package.json +++ b/apps/viewer/package.json @@ -9,11 +9,10 @@ "lint": "eslint", "typecheck": "tsc --noEmit" }, - "packageManager": "pnpm@10.10.0", "dependencies": { "@uidotdev/usehooks": "^2.4.1", "lucide-react": "^0.511.0", - "next": "16.1.2", + "next": "16.1.3", "pdfjs-dist": "4.8.69", "react": "19.2.3", "react-dom": "19.2.3", @@ -27,9 +26,10 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", - "eslint-config-next": "16.1.2", + "eslint-config-next": "16.1.3", "postcss": "^8", "tailwindcss": "^4", "typescript": "^5" - } + }, + "packageManager": "pnpm@10.10.0" } diff --git a/database/database.types.ts b/database/database.types.ts index 1de17bf7b1..2eb50e2129 100644 --- a/database/database.types.ts +++ b/database/database.types.ts @@ -27,6 +27,13 @@ export type Database = MergeDeep< Row: DatabaseGenerated["public"]["Tables"]["customer"]["Row"] & { tags: string[]; }; + /** + * `customer_with_tags` is a view with an INSTEAD OF INSERT trigger. + * We model Insert so client code can insert without `any` casts. + */ + Insert: DatabaseGenerated["public"]["Tables"]["customer"]["Insert"] & { + tags?: string[] | null; + }; }; }; }; diff --git a/editor/app/(api)/(public)/v1/session/[session]/field/[field]/challenge/email/start/route.ts b/editor/app/(api)/(public)/v1/session/[session]/field/[field]/challenge/email/start/route.ts index 76570c573e..47bcaac8ed 100644 --- a/editor/app/(api)/(public)/v1/session/[session]/field/[field]/challenge/email/start/route.ts +++ b/editor/app/(api)/(public)/v1/session/[session]/field/[field]/challenge/email/start/route.ts @@ -122,13 +122,44 @@ export async function POST( .select("lang") .eq("form_id", ctx.form.id) .single(); + + if (!formDoc) { + return NextResponse.json({ error: "form not found" }, { status: 404 }); + } + + // Resolve brand info for email sender/display. + // Prefer published tenant branding (`www.title` / `www.publisher`) when available, + // otherwise fall back to a generic placeholder. (Do not expose internal project names.) + const { data: www_list, error: www_err } = await service_role.www + .from("www") + .select("title, publisher, lang") + .eq("project_id", ctx.form.project_id) + .limit(1); + + const www = !www_err && www_list && www_list.length > 0 ? www_list[0] : null; + + const brand_name = + www && typeof www.title === "string" && www.title + ? String(www.title) + : "(Untitled)"; + + const publisher = + www && typeof www.publisher === "string" && www.publisher + ? String(www.publisher) + : ""; + const brand_support_url = + publisher.startsWith("http://") || publisher.startsWith("https://") + ? publisher + : undefined; + const brand_support_contact = publisher.includes("@") ? publisher : undefined; + + const langCandidate = + www && typeof www.lang === "string" && www.lang ? www.lang : formDoc.lang; const emailLang: CIAMVerificationEmailLang = select_lang( - (formDoc as { lang?: unknown } | null)?.lang, + langCandidate, supported_languages, "en" ); - - const brand_name = "Grida"; const { error: resend_err } = await resend.emails.send({ from: `${brand_name} `, to: email, @@ -138,6 +169,8 @@ export async function POST( brand_name, expires_in_minutes, lang: emailLang, + brand_support_url, + brand_support_contact, }), }); diff --git a/editor/app/(dev)/ui/components/tags/_page.tsx b/editor/app/(dev)/ui/components/tags/_page.tsx new file mode 100644 index 0000000000..0424f111d7 --- /dev/null +++ b/editor/app/(dev)/ui/components/tags/_page.tsx @@ -0,0 +1,162 @@ +"use client"; + +import React, { useState } from "react"; +import { TagInput } from "@/components/tag"; +import { ComponentDemo } from "../component-demo"; + +export default function TagsPage() { + const [tags, setTags] = useState<{ id: string; text: string }[]>([ + { id: "react", text: "react" }, + { id: "typescript", text: "typescript" }, + ]); + const [tagsWithAutocomplete, setTagsWithAutocomplete] = useState< + { id: string; text: string }[] + >([]); + const [emptyTags, setEmptyTags] = useState<{ id: string; text: string }[]>( + [] + ); + const [coloredTags, setColoredTags] = useState< + { id: string; text: string; color?: string }[] + >([{ id: "urgent", text: "urgent", color: "#ef4444" }]); + + const autocompleteOptions = [ + { id: "apple", text: "apple" }, + { id: "banana", text: "banana" }, + { id: "cherry", text: "cherry" }, + { id: "date", text: "date" }, + { id: "elderberry", text: "elderberry" }, + { id: "fig", text: "fig" }, + { id: "grape", text: "grape" }, + { id: "honeydew", text: "honeydew" }, + { id: "kiwi", text: "kiwi" }, + { id: "lemon", text: "lemon" }, + { id: "mango", text: "mango" }, + { id: "orange", text: "orange" }, + { id: "papaya", text: "papaya" }, + { id: "raspberry", text: "raspberry" }, + { id: "strawberry", text: "strawberry" }, + ]; + + const coloredAutocompleteOptions = [ + { id: "urgent", text: "urgent", color: "#ef4444" }, + { id: "review", text: "review", color: "#f59e0b" }, + { id: "planned", text: "planned", color: "#3b82f6" }, + { id: "done", text: "done", color: "#22c55e" }, + { id: "blocked", text: "blocked", color: "#a855f7" }, + ]; + + return ( +
+
+
+

Tag Input

+

+ A flexible tag input component with autocomplete support for + managing multiple values. +

+
+ +
+ +
+
+

Basic Usage

+

+ Create and manage tags with keyboard support +

+
+ + Tips: Type and press Enter to add tags. Click + on a tag to remove it. + + } + > + {}} + /> + +
+ +
+ +
+
+

With Autocomplete

+

+ Enable autocomplete suggestions for faster input +

+
+ + Tips: Start typing to see autocomplete + suggestions. Select from the dropdown or create custom tags. + + } + > + {}} + /> + +
+ +
+ +
+
+

With Colors

+

+ Autocomplete and selected tags can be color-coded. +

+
+ + Tips: Pick from the colored suggestions to see + the chips tinted, Notion-style. + + } + > + {}} + /> + +
+ +
+ +
+
+

Empty State

+

+ Tag input without any initial values +

+
+ + {}} + /> + +
+
+
+ ); +} diff --git a/editor/app/(dev)/ui/components/tags/page.tsx b/editor/app/(dev)/ui/components/tags/page.tsx index a4d382d414..476173f381 100644 --- a/editor/app/(dev)/ui/components/tags/page.tsx +++ b/editor/app/(dev)/ui/components/tags/page.tsx @@ -1,176 +1,17 @@ -"use client"; - -import React, { useState } from "react"; -import { TagInput } from "@/components/tag"; -import { ComponentDemo } from "../component-demo"; +import _TagsPage from "./_page"; + +export const metadata = { + title: "Tag Input | Grida", + description: "Tag input with autocomplete for managing multiple values", + keywords: [ + "tag input", + "autocomplete", + "multiple values", + "tag management", + "tag input component", + ], +}; export default function TagsPage() { - const [tags, setTags] = useState<{ id: string; text: string }[]>([ - { id: "react", text: "react" }, - { id: "typescript", text: "typescript" }, - ]); - const [tagsWithAutocomplete, setTagsWithAutocomplete] = useState< - { id: string; text: string }[] - >([]); - const [emptyTags, setEmptyTags] = useState<{ id: string; text: string }[]>( - [] - ); - - const autocompleteOptions = [ - { id: "apple", text: "apple" }, - { id: "banana", text: "banana" }, - { id: "cherry", text: "cherry" }, - { id: "date", text: "date" }, - { id: "elderberry", text: "elderberry" }, - { id: "fig", text: "fig" }, - { id: "grape", text: "grape" }, - { id: "honeydew", text: "honeydew" }, - { id: "kiwi", text: "kiwi" }, - { id: "lemon", text: "lemon" }, - { id: "mango", text: "mango" }, - { id: "orange", text: "orange" }, - { id: "papaya", text: "papaya" }, - { id: "raspberry", text: "raspberry" }, - { id: "strawberry", text: "strawberry" }, - ]; - - return ( -
-
-
-

Tag Input

-

- A flexible tag input component with autocomplete support for - managing multiple values. -

-
- -
- -
-
-

Basic Usage

-

- Create and manage tags with keyboard support -

-
- - Tips: Type and press Enter to add tags. Click - on a tag to remove it. - - } - > - {}} - /> - -
- -
- -
-
-

With Autocomplete

-

- Enable autocomplete suggestions for faster input -

-
- - Tips: Start typing to see autocomplete - suggestions. Select from the dropdown or create custom tags. - - } - > - {}} - /> - -
- -
- -
-
-

Empty State

-

- Tag input without any initial values -

-
- - {}} - /> - -
- -
- -
-
-

Current Tags

-

View all active tags

-
-
-
-

Basic Tags

-
- {tags.length > 0 ? ( - tags.map((tag) => ( -
- • {tag.text} -
- )) - ) : ( -
No tags
- )} -
-
-
-

Autocomplete Tags

-
- {tagsWithAutocomplete.length > 0 ? ( - tagsWithAutocomplete.map((tag) => ( -
- • {tag.text} -
- )) - ) : ( -
No tags
- )} -
-
-
-

Empty Tags

-
- {emptyTags.length > 0 ? ( - emptyTags.map((tag) => ( -
- • {tag.text} -
- )) - ) : ( -
No tags
- )} -
-
-
-
-
-
- ); + return <_TagsPage />; } diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/_components/quests-table.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/_components/quests-table.tsx index 707b14ff62..97a2b78294 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/_components/quests-table.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/_components/quests-table.tsx @@ -28,7 +28,7 @@ import { CheckCircle2, Clock, AlertCircle, - Plus, + Infinity, } from "lucide-react"; import { DropdownMenu, @@ -87,8 +87,7 @@ export function QuestsTable() { const { tokens } = useReferrerQuests(campaign.id); const project = useProject(); - // FIXME: - const max_invitations_per_referrer = 10; + const max_invitations_per_referrer = campaign.max_invitations_per_referrer; const toggleQuestExpand = (questId: string) => { setExpandedQuests((prev) => @@ -98,38 +97,51 @@ export function QuestsTable() { ); }; + const BADGE_PRESETS = { + quest_status: { + active: { + label: "Active", + className: "bg-green-50 text-green-700 border-green-200", + }, + completed: { + label: "Completed", + className: "bg-blue-50 text-blue-700 border-blue-200", + }, + expired: { + label: "Expired", + className: "bg-amber-50 text-amber-700 border-amber-200", + }, + }, + claim_status: { + claimed: { + label: "Claimed", + className: "bg-green-50 text-green-700 border-green-200", + }, + not_claimed: { + label: "Not claimed", + className: "bg-amber-50 text-amber-700 border-amber-200", + }, + }, + } as const; + const getStatusBadge = (status: "active" | "completed" | "expired") => { - switch (status) { - case "active": - return ( - - Active - - ); - case "completed": - return ( - - Completed - - ); - case "expired": - return ( - - Expired - - ); - default: - return {status}; - } + const preset = BADGE_PRESETS.quest_status[status]; + return ( + + {preset.label} + + ); + }; + + const getClaimStatusBadge = (is_claimed: boolean) => { + const preset = is_claimed + ? BADGE_PRESETS.claim_status.claimed + : BADGE_PRESETS.claim_status.not_claimed; + return ( + + {preset.label} + + ); }; if (!tokens) { @@ -216,35 +228,48 @@ export function QuestsTable() { {QUESTNAME} -
+ {max_invitations_per_referrer !== null ? ( +
+
+ {Math.round( + (quest.invitation_count / + max_invitations_per_referrer) * + 100 + )} + % +
+ +
+ ) : (
- {(quest.invitation_count / - (max_invitations_per_referrer ?? 0)) * - 100} - % + Unlimited
- -
+ )}
- - {quest.invitation_count} /{" "} - {max_invitations_per_referrer ?? "∞"} - +
+ {quest.invitation_count} / + {max_invitations_per_referrer !== null ? ( + {max_invitations_per_referrer} + ) : ( + + )} +
{getStatusBadge( - quest.invitation_count === max_invitations_per_referrer + max_invitations_per_referrer !== null && + quest.invitation_count === max_invitations_per_referrer ? "completed" : "active" )} @@ -301,9 +326,6 @@ export function QuestsTable() { Onboarding Step 1: Claim Step 2: Submit Form - - Step 3: Complete Test Drive - Status @@ -345,13 +367,8 @@ export function QuestsTable() { - - - {challenge.is_claimed - ? "claimed" - : "not claimed"} - + {getClaimStatusBadge(challenge.is_claimed)} {/* {challenge.steps.every( (step) => step.completed ) ? ( diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/store.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/store.tsx index f1aace5970..89e92fa620 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/store.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(campaign)/campaigns/[campaign]/store.tsx @@ -6,6 +6,7 @@ const CampaignContext = createContext<{ id: string; title: string; layout_id: string | null; + max_invitations_per_referrer: number | null; } | null>(null); export function CampaignProvider({ @@ -17,6 +18,7 @@ export function CampaignProvider({ id: string; title: string; layout_id: string | null; + max_invitations_per_referrer: number | null; }; }) { return ( diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/customers/[uid]/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/customers/[uid]/page.tsx index 7c4277fb79..e7032b2c7e 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/customers/[uid]/page.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/customers/[uid]/page.tsx @@ -21,7 +21,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { FormCustomerDetail } from "@/app/(api)/private/editor/customers/[uid]/route"; import useSWR, { mutate } from "swr"; import { Spinner } from "@/components/ui/spinner"; -import { ClockIcon, Cross1Icon, Link2Icon } from "@radix-ui/react-icons"; +import { ClockIcon, Link2Icon } from "@radix-ui/react-icons"; import { ArrowLeft, ChevronDown, @@ -51,8 +51,9 @@ import { import React, { useCallback, useMemo, useState, use } from "react"; import { toast } from "sonner"; import { useDialogState } from "@/components/hooks/use-dialog-state"; -import CustomerEditDialog, { - CustomerEditDialogDTO, +import { + CustomerContactsEditDialog, + CustomerContactsEditDialogDTO, } from "@/scaffolds/platform/customer/customer-edit-dialog"; import { Dialog, @@ -103,7 +104,7 @@ function useCustomer(project_id: number, uid: string) { }, [uid, supabase]); const _update = useCallback( - async (data: CustomerEditDialogDTO) => { + async (data: CustomerContactsEditDialogDTO) => { const { error } = await supabase .from("customer") .update(data) @@ -198,7 +199,6 @@ function useCustomer(project_id: number, uid: string) { update_tags: _update_tags, update_marketing: _update_marketing, }), - // eslint-disable-next-line react-hooks/exhaustive-deps [uid, supabase] ); } @@ -274,7 +274,7 @@ export default function CustomerDetailPage(props0: { } }; - const onUpdateCustomer = async (data: CustomerEditDialogDTO) => { + const onUpdateCustomer = async (data: CustomerContactsEditDialogDTO) => { const success = await actions.update(data); mutate(key); @@ -389,10 +389,9 @@ export default function CustomerDetailPage(props0: { options={allTags.map((t) => t.name)} onSave={onUpdateCustomerTags} /> - @@ -821,7 +820,7 @@ function MetadataEditDialog({ await onSave?.(data).then((success) => { if (success) props.onOpenChange?.(false); }); - } catch (e) { + } catch { toast.error("Invalid JSON"); } }; @@ -829,6 +828,9 @@ function MetadataEditDialog({ return ( + + Edit customer metadata + createBrowserClient(), []); const [selection, setSelection] = useState>(new Set()); + const { tags: projectTags } = useTags(); + const tagOptions = useMemo( + () => projectTags.map((t) => ({ value: t.name, color: t.color })), + [projectTags] + ); + const predicateConfig = useMemo( + () => ({ + getEnumOptions: (meta) => { + if (meta.name === "tags" && meta.array && meta.scalar_format === "text") + return tagOptions; + return undefined; + }, + getDefaultPredicate: (meta) => { + if (meta.name === "tags" && meta.array && meta.scalar_format === "text") + return { op: "ov" }; + return undefined; + }, + }), + [tagOptions] + ); const has_selected_rows = selection.size > 0; @@ -142,154 +154,160 @@ function Body() { provider: "grida", }} > - - - - - - - {has_selected_rows ? ( -
-
-
- - - {txt_n_plural(selection.size, "customer")} selected - + + + + + + + + {has_selected_rows ? ( +
+
+
+ + + {txt_n_plural(selection.size, "customer")}{" "} + selected + +
+ + {/* */} + {/* */} + { + onDeleteCustomers( + Array.from(selection.values()).map((v) => v) + ); + }} + />
- - {/* */} - {/* */} - { - onDeleteCustomers( - Array.from(selection.values()).map((v) => v) - ); - }} - />
-
- ) : ( - <> -
- - - - +
+ + + + + Customer + + + + + + - Customer - - - - - - - - - + + + + { + if (v.trim()) { + tablespace.onTextSearch( + Platform.Customer.TABLE_SEARCH_TEXT, + v.trim() + ); + } else { + tablespace.onTextSearchClear(); + } + }} + debounce={500} /> - - { - if (v.trim()) { - tablespace.onTextSearch( - Platform.Customer.TABLE_SEARCH_TEXT, - v.trim() - ); - } else { - tablespace.onTextSearchClear(); - } - }} - debounce={500} - /> - -
- - )} - - - {/* */} - { - tablespace.onRefresh(); - }} - /> - - - {(tablespace.isPredicatesSet || tablespace.isOrderbySet) && ( - - - -
- -
-
+ +
+ + )} + + + {/* */} + { + tablespace.onRefresh(); + }} + /> + - )} - - - - { - router.push(`${pathname}/${row.uid}`); - // - }} - /> - - - - - - - - - - + {(tablespace.isPredicatesSet || tablespace.isOrderbySet) && ( + + + +
+ +
+
+
+ )} + + + + { + router.push(`${pathname}/${row.uid}`); + // + }} + /> + + + + + + + + + + + ); } @@ -297,7 +315,8 @@ function Body() { function NewButton({ onNewData }: { onNewData?: () => void }) { const project = useProject(); const project_id = project.id; - const client = useMemo(() => createBrowserClient(), []); + const ciamClient = useMemo(() => createBrowserCIAMClient(), []); + const { tags: allTags } = useTags(); const createCustomerDialog = useDialogState("create-customer", { refreshkey: true, }); @@ -316,12 +335,15 @@ function NewButton({ onNewData }: { onNewData?: () => void }) { return (
- t.name)} onSubmit={async (data) => { - const { error } = await insertCustomer(client, project_id, data); + const { error } = await insertCustomer(ciamClient, project_id, { + ...data, + }); + if (error) { console.error("Failed to create customer", error); toast.error("Failed to create customer"); @@ -372,73 +394,3 @@ function NewButton({ onNewData }: { onNewData?: () => void }) {
); } - -function ViewSettings() { - return ( - - - - - - View Options - - - Data Consistency & Protection - - { - // dispatch({ - // type: "editor/data-grid/local-filter", - // masking_enabled: checked, - // }); - // }} - > - Mask data{" "} - - Locally - - - - {/* */} - {/* date format */} - - Date Format - - { - // - }} - /> - - - - Date Timezone - - {/* tz */} - { - // - }} - /> - - - ); -} diff --git a/editor/components/ai-elements/code-block.tsx b/editor/components/ai-elements/code-block.tsx index 7738f3da3d..0711c5b13c 100644 --- a/editor/components/ai-elements/code-block.tsx +++ b/editor/components/ai-elements/code-block.tsx @@ -2,7 +2,6 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/components/lib/utils/index"; -import type { Element } from "hast"; import { CheckIcon, CopyIcon } from "lucide-react"; import { type ComponentProps, @@ -31,7 +30,7 @@ const CodeBlockContext = createContext({ const lineNumberTransformer: ShikiTransformer = { name: "line-numbers", - line(node: Element, line: number) { + line(node, line) { node.children.unshift({ type: "element", tagName: "span", @@ -110,12 +109,12 @@ export const CodeBlock = ({ >
diff --git a/editor/components/formfield/email-challenge.tsx b/editor/components/formfield/email-challenge.tsx index 540522f84f..4b257f44b2 100644 --- a/editor/components/formfield/email-challenge.tsx +++ b/editor/components/formfield/email-challenge.tsx @@ -230,6 +230,7 @@ function _EmailChallenge({ label, placeholder, required, + requiredAsterisk = true, disabled, sendPending, i18n, @@ -250,6 +251,7 @@ function _EmailChallenge({ label?: string; placeholder?: string; required?: boolean; + requiredAsterisk?: boolean; disabled?: boolean; sendPending?: boolean; i18n?: EmailChallengeI18n; @@ -283,7 +285,12 @@ function _EmailChallenge({ return (
{label && ( - + )} @@ -509,6 +516,7 @@ export function EmailChallenge({ label, placeholder, required, + requiredAsterisk = true, disabled, otpLength, otpType, @@ -520,6 +528,7 @@ export function EmailChallenge({ label?: string; placeholder?: string; required?: boolean; + requiredAsterisk?: boolean; disabled?: boolean; otpLength?: number; otpType?: "text" | "numeric"; @@ -552,6 +561,7 @@ export function EmailChallenge({ label={label} placeholder={placeholder} required={required} + requiredAsterisk={requiredAsterisk} disabled={disabled} sendPending={false} i18n={defaultEmailChallengeI18nEn} @@ -584,6 +594,7 @@ export function ChallengeEmailField({ label, placeholder, required, + requiredAsterisk = true, disabled, otpLength = 6, otpType = "numeric", @@ -599,6 +610,7 @@ export function ChallengeEmailField({ label?: string; placeholder?: string; required?: boolean; + requiredAsterisk?: boolean; disabled?: boolean; otpLength?: number; otpType?: "text" | "numeric"; @@ -641,7 +653,6 @@ export function ChallengeEmailField({ if (lastAutoVerifyOtpRef.current === otp) return; lastAutoVerifyOtpRef.current = otp; void onVerifyClick(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [otp, otpLength, verifyPending, sessionState.challenge_id, widgetState]); React.useEffect(() => { @@ -665,7 +676,6 @@ export function ChallengeEmailField({ cancelled = true; }; // intentionally omit `email` from deps: we only want to hydrate once - // eslint-disable-next-line react-hooks/exhaustive-deps }, [provider, effectiveSessionId, effectiveFieldId]); const canSend = useMemo(() => { @@ -732,6 +742,7 @@ export function ChallengeEmailField({ label={label} placeholder={placeholder} required={required} + requiredAsterisk={requiredAsterisk} disabled={disabled} sendPending={sendPending} verifyPending={verifyPending} @@ -762,6 +773,7 @@ export function EmailChallengePreview({ label, placeholder, required, + requiredAsterisk = true, disabled, otpLength, otpType, @@ -772,6 +784,7 @@ export function EmailChallengePreview({ label?: string; placeholder?: string; required?: boolean; + requiredAsterisk?: boolean; disabled?: boolean; otpLength?: number; otpType?: "text" | "numeric"; @@ -813,6 +826,7 @@ export function EmailChallengePreview({ label={label} placeholder={placeholder} required={required} + requiredAsterisk={requiredAsterisk} disabled={disabled} sendPending={false} i18n={defaultEmailChallengeI18nEn} diff --git a/editor/components/formfield/form-field.tsx b/editor/components/formfield/form-field.tsx index a8d554b66f..e869b3ff6c 100644 --- a/editor/components/formfield/form-field.tsx +++ b/editor/components/formfield/form-field.tsx @@ -317,6 +317,7 @@ function MonoFormField({ label={label} placeholder={placeholder} required={required} + requiredAsterisk={requiredAsterisk} disabled={disabled} /> ); @@ -330,6 +331,7 @@ function MonoFormField({ label={label} placeholder={placeholder} required={required} + requiredAsterisk={requiredAsterisk} disabled={disabled} /> ); diff --git a/editor/components/tag/autocomplete.tsx b/editor/components/tag/autocomplete.tsx index 17a07a5829..111e0103ec 100644 --- a/editor/components/tag/autocomplete.tsx +++ b/editor/components/tag/autocomplete.tsx @@ -303,6 +303,12 @@ export const Autocomplete: React.FC = ({ onClick={() => toggleTag(option)} >
+ {option.color && ( + + )} {option.text} {tags.some((tag) => tag.text === option.text) && ( = ({ tagClasses, disabled, }) => { + const style: React.CSSProperties | undefined = tagObj.color + ? ({ + /** + * We keep the styling simple and avoid manual hex->rgba conversion. + * `color-mix()` is widely supported in modern browsers (Chromium/Safari/Firefox). + */ + ["--tag-color" as keyof React.CSSProperties]: tagObj.color, + borderColor: "var(--tag-color)", + backgroundColor: + "color-mix(in srgb, var(--tag-color) 12%, transparent)", + } satisfies React.CSSProperties) + : undefined; + return ( = ({ tagClasses?.body )} onClick={() => onTagClick?.(tagObj)} + style={style} > + {tagObj.color && ( + + )} {tagObj.text} + + + + + + ); +} + +function PredicateValueEditor({ + predicate, + meta, + platform, + schema_name, + predicateConfig, + onValueChange, +}: { + predicate: Data.Query.Predicate.ExtendedPredicate; + meta: Data.Relation.Attribute; + platform: ReturnType; + schema_name: string | undefined; + predicateConfig: ReturnType; + onValueChange: (value: SQLLiteralInputValue) => void; +}) { + const enumOptionsRaw = meta.array + ? predicateConfig?.getEnumOptions?.(meta) + : undefined; + const enumOptions = normalizeEnumOptions(enumOptionsRaw); + const isArrayEnum = Array.isArray(enumOptions) && enumOptions.length > 0; + const isArray = !!meta.array; + + if (isArrayEnum) { + return ( + onValueChange(v ?? undefined)} + options={enumOptions} + /> + ); + } + + if (isArray) { + return ( + onValueChange(v ?? undefined)} + /> + ); + } + + if (platform.provider === "x-supabase") { + return ( + + ); + } + + return ( + + ); +} + export function DataQueryPredicatesMenu({ children, + asChild, ...props -}: React.PropsWithChildren) { +}: React.PropsWithChildren< + IDataQueryPredicatesConsumer & { + asChild?: boolean; + } +>) { const { isPredicatesSet: isset, predicates, @@ -71,17 +260,20 @@ export function DataQueryPredicatesMenu({ const schema_name = useSchemaName(); const platform = useDataPlatform(); + const predicateConfig = usePredicateConfig(); if (!isset) { return ( - {children} + + {children} + ); } return ( <> - {children} + {children}