From d706e9bd1c372efd9cc03ad8e015bb8d2d2f27d0 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 18 Jan 2026 20:22:10 +0900 Subject: [PATCH 01/13] Add requiredAsterisk prop to EmailChallenge components for better label indication --- .../components/formfield/email-challenge.tsx | 20 ++++++++++++++++--- editor/components/formfield/form-field.tsx | 2 ++ editor/grida-forms-hosted/e/formview.tsx | 1 + 3 files changed, 20 insertions(+), 3 deletions(-) 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/grida-forms-hosted/e/formview.tsx b/editor/grida-forms-hosted/e/formview.tsx index 3525d82015..8ee09bb43d 100644 --- a/editor/grida-forms-hosted/e/formview.tsx +++ b/editor/grida-forms-hosted/e/formview.tsx @@ -553,6 +553,7 @@ function BlockRenderer({ label={field.label ?? field.name} placeholder={field.placeholder ?? "alice@example.com"} required={field.required} + requiredAsterisk disabled={is_not_in_current_section_nor_root || hidden} i18n={emailChallengeTranslation} /> From 2264ed9e3828d1f3dd4f410ce9796ff6a527df0d Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 18 Jan 2026 20:31:08 +0900 Subject: [PATCH 02/13] drawer with scrolling --- .../west-referral/invitation/page.tsx | 115 +++++++++--------- 1 file changed, 59 insertions(+), 56 deletions(-) diff --git a/editor/theme/templates/enterprise/west-referral/invitation/page.tsx b/editor/theme/templates/enterprise/west-referral/invitation/page.tsx index e328e67126..12cdd4f596 100644 --- a/editor/theme/templates/enterprise/west-referral/invitation/page.tsx +++ b/editor/theme/templates/enterprise/west-referral/invitation/page.tsx @@ -1,7 +1,6 @@ "use client"; import React, { useEffect, useState } from "react"; -import { Label } from "@/components/ui/label"; import { Card, CardContent, @@ -13,9 +12,7 @@ import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, - DrawerDescription, DrawerFooter, - DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import { FormView } from "@/grida-forms-hosted/e"; @@ -28,7 +25,6 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { TicketCheckIcon } from "lucide-react"; import { ShineBorder } from "@/www/ui/shine-border"; -import Link from "next/link"; import * as Standard from "@/theme/templates/enterprise/west-referral/standard"; import { useDialogState } from "@/components/hooks/use-dialog-state"; import { template } from "@/utils/template"; @@ -113,8 +109,10 @@ export default function InvitationPageTemplate({ }; client?: Platform.WEST.Referral.WestReferralClient; }) { + if (!visible) return null; + const t = dictionary[locale]; - const { code, campaign, referrer_name: _referrer_name, is_claimed } = data; + const { code, referrer_name: _referrer_name, is_claimed } = data; const referrer_name = _referrer_name || t.an_anonymous; const router = useRouter(); const [claimed, setClaimed] = useState(is_claimed); @@ -304,9 +302,43 @@ function SignUpForm({ const isOpen = props.open === true; const hasForm = typeof form_id === "string" && form_id.length > 0; + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + const formdata = new FormData(e.target as HTMLFormElement); + const submit_json = await submitFormToDefaultEndpoint<{ + customer_id?: string | null; + }>(form_id, formdata); + + const customer_id = + submit_json?.data?.customer_id && + typeof submit_json.data.customer_id === "string" + ? submit_json.data.customer_id + : null; + + if (!customer_id) { + toast.error(copy.event_signup_fail); + return; + } + + if (!client) { + toast.error(copy.event_signup_fail); + return; + } + + const ok = await client.claim(invitation_code, customer_id); + if (!ok) { + toast.error(copy.event_signup_fail); + return; + } + + toast.success(copy.event_signup_success); + onClaimed?.(); + }; + return ( - + Mission Signup Form {/* Only mount the form session + loading state when the drawer is open. @@ -314,51 +346,26 @@ function SignUpForm({ {isOpen ? ( hasForm ? ( - { - e.preventDefault(); - - const formdata = new FormData(e.target as HTMLFormElement); - const submit_json = await submitFormToDefaultEndpoint<{ - customer_id?: string | null; - }>(form_id, formdata); - - const customer_id = - submit_json?.data?.customer_id && - typeof submit_json.data.customer_id === "string" - ? submit_json.data.customer_id - : null; - - if (!customer_id) { - toast.error(copy.event_signup_fail); - return; - } - - if (!client) { - toast.error(copy.event_signup_fail); - return; - } - - const ok = await client.claim(invitation_code, customer_id); - if (!ok) { - toast.error(copy.event_signup_fail); - return; - } - - toast.success(copy.event_signup_success); - onClaimed?.(); - }} - className="max-w-full" - config={{ - is_powered_by_branding_enabled: false, - }} - /> +
+ {/* Scrollable form area (critical for small screens). */} +
+ +
- - Previous - Next - Save - + {/* Fixed action area */} + {/* TODO: have i18n */} + + Previous + Next + Save + +
) : (
@@ -383,11 +390,7 @@ function FormViewProvider({ // (or allow callers to fully control loading UX). For now, we keep a simple // built-in skeleton and only mount this provider when the dialog is open. const { session, clearSessionStorage } = useRequestFormSession(form_id); - const { - data: res, - error: servererror, - isLoading, - } = useFormSession(form_id, { + const { data: res, isLoading } = useFormSession(form_id, { mode: "signed", session_id: session, // TODO: not implemented @@ -400,7 +403,7 @@ function FormViewProvider({ }; }, []); - const { data, error } = res || {}; + const { data } = res || {}; if (isLoading || !session || !data) { return ( From 9339fa2788b59a0984a188d786dc8abce9e422b4 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 18 Jan 2026 20:43:04 +0900 Subject: [PATCH 03/13] trim sharable --- .../west-referral/referrer/page.tsx | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/editor/theme/templates/enterprise/west-referral/referrer/page.tsx b/editor/theme/templates/enterprise/west-referral/referrer/page.tsx index 0a19000367..ddd3c708a0 100644 --- a/editor/theme/templates/enterprise/west-referral/referrer/page.tsx +++ b/editor/theme/templates/enterprise/west-referral/referrer/page.tsx @@ -49,7 +49,9 @@ function renderSharable({ } return { - text: template(template_text, context), + // Some mobile share targets are sensitive to trailing newlines/spaces. + // Normalize to avoid extra empty lines being introduced. + text: template(template_text, context).trimEnd(), url: context.url, }; } @@ -95,14 +97,20 @@ async function reshare({ async function share_or_copy( sharable: WebSharePayload ): Promise<{ type: "clipboard" | "share" }> { + const normalized: WebSharePayload = { + // Some share targets add their own separators; keep our payload clean. + title: sharable.title?.trim(), + text: sharable.text?.trimEnd(), + url: sharable.url?.trim(), + }; + if (navigator.share) { - await navigator.share(sharable); + await navigator.share(normalized); return { type: "share" }; } else { - const shareUrl = sharable.url; - const shareText = sharable.text; - const shareTitle = sharable.title; - const shareContent = `${shareTitle}\n${shareText}\n${shareUrl}`; + const shareContent = [normalized.title, normalized.text, normalized.url] + .filter((v): v is string => typeof v === "string" && v.length > 0) + .join("\n"); await navigator.clipboard.writeText(shareContent); return { type: "clipboard" }; } From 6b2a0106e92d601a7a514e5ecdd745fcf4acae43 Mon Sep 17 00:00:00 2001 From: Universe Date: Sun, 18 Jan 2026 21:07:29 +0900 Subject: [PATCH 04/13] a11y --- .../[proj]/(console)/(resources)/customers/[uid]/page.tsx | 3 +++ 1 file changed, 3 insertions(+) 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..c0b70f449b 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 @@ -829,6 +829,9 @@ function MetadataEditDialog({ return ( + + Edit customer metadata + Date: Sun, 18 Jan 2026 21:12:46 +0900 Subject: [PATCH 05/13] Refactor customer management dialogs and types to support tags. Introduce CustomerCreateDialog for creating customers with tags, and update CustomerContactsEditDialog for editing contact information. Adjust database types to include tags in customer insert operations. --- database/database.types.ts | 7 + .../(resources)/customers/[uid]/page.tsx | 17 +- .../(console)/(resources)/customers/page.tsx | 98 +----- .../customer/customer-edit-dialog.tsx | 316 ++++++++++++++---- .../platform/customer/use-customer-feed.ts | 12 +- 5 files changed, 284 insertions(+), 166 deletions(-) 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/(workbench)/[org]/[proj]/(console)/(resources)/customers/[uid]/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/customers/[uid]/page.tsx index c0b70f449b..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"); } }; diff --git a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/customers/page.tsx b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/customers/page.tsx index 591014ae37..4c881207b2 100644 --- a/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/customers/page.tsx +++ b/editor/app/(workbench)/[org]/[proj]/(console)/(resources)/customers/page.tsx @@ -36,35 +36,25 @@ import { ResourceTypeIcon } from "@/components/resource-type-icon"; import { Button, buttonVariants } from "@/components/ui/button"; import { DropdownMenu, - DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { - ChevronDownIcon, - Cross2Icon, - GearIcon, - UploadIcon, -} from "@radix-ui/react-icons"; +import { ChevronDownIcon, Cross2Icon, UploadIcon } from "@radix-ui/react-icons"; import { useProject } from "@/scaffolds/workspace"; import { useDialogState } from "@/components/hooks/use-dialog-state"; import { ImportCSVDialog } from "@/scaffolds/platform/customer/import-csv-dialog"; import { usePathname, useRouter } from "next/navigation"; import { subscribeTable } from "@/lib/supabase/realtime"; -import { Badge } from "@/components/ui/badge"; -import { DateFormatRadioGroup } from "@/scaffolds/data-format/ui/date-format"; -import { DateTimeZoneRadioGroup } from "@/scaffolds/data-format/ui/date-timezone"; import { cn } from "@/components/lib/utils"; -import CustomerEditDialog from "@/scaffolds/platform/customer/customer-edit-dialog"; +import CustomerCreateDialog from "@/scaffolds/platform/customer/customer-edit-dialog"; import { toast } from "sonner"; import { Platform } from "@/lib/platform"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import { TableQueryChips } from "@/scaffolds/grid-editor/components/query/query-chips"; import { txt_n_plural } from "@/utils/plural"; import { DeleteSelectionButton } from "@/scaffolds/grid-editor/components/delete"; +import { useTags } from "@/scaffolds/workspace"; export default function Customers() { return ( @@ -297,7 +287,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 +307,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 +366,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/scaffolds/platform/customer/customer-edit-dialog.tsx b/editor/scaffolds/platform/customer/customer-edit-dialog.tsx index 3c7edf3544..644780af3a 100644 --- a/editor/scaffolds/platform/customer/customer-edit-dialog.tsx +++ b/editor/scaffolds/platform/customer/customer-edit-dialog.tsx @@ -12,52 +12,224 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; import { PhoneInput } from "@/components/extension/phone-input"; import { Textarea } from "@/components/ui/textarea"; +import { TagInput } from "@/components/tag"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@/components/ui/field"; -export interface CustomerEditDialogDTO { +export interface CustomerContactsEditDialogDTO { name: string | null; email: string | null; phone: string | null; description: string | null; } -const t_operation_action_label = { - insert: "create", - update: "update", -} as const; +export interface CustomerCreateDialogDTO extends CustomerContactsEditDialogDTO { + tags: string[]; +} -export default function CustomerEditDialog({ +export function CustomerCreateDialog({ default: defaultValues = { name: "", email: "", phone: "", description: "", + tags: [], }, - operation, onSubmit, + tagOptions, ...props }: React.ComponentProps & { - operation: "insert" | "update"; - default?: CustomerEditDialogDTO; - onSubmit?: (data: CustomerEditDialogDTO) => Promise; + default?: CustomerContactsEditDialogDTO & { tags?: string[] }; + onSubmit?: (data: CustomerCreateDialogDTO) => Promise; + /** + * Autocomplete options (tag names). Freeform tags are still allowed. + */ + tagOptions?: string[]; }) { const { control, register, handleSubmit, formState: { isSubmitting }, - } = useForm({ - defaultValues: defaultValues, + } = useForm({ + defaultValues: { + name: defaultValues.name ?? "", + email: defaultValues.email ?? "", + phone: defaultValues.phone ?? "", + description: defaultValues.description ?? "", + }, }); - const t_action_label = t_operation_action_label[operation]; + const [activeTagIndex, setActiveTagIndex] = React.useState( + null + ); + const [tags, setTags] = React.useState<{ id: string; text: string }[]>(() => + (defaultValues.tags ?? []).map((t) => ({ id: t, text: t })) + ); - const onFormSubmit = async (data: CustomerEditDialogDTO) => { + const onFormSubmit = async (data: CustomerContactsEditDialogDTO) => { // Transform falsy (empty) values to null. - const transformedData: CustomerEditDialogDTO = { + const transformedData: CustomerCreateDialogDTO = { + name: data.name?.trim() || null, + email: data.email?.trim() || null, + phone: data.phone?.trim() || null, + description: data.description?.trim() || null, + tags: tags.map((t) => t.text.trim()).filter((t) => t.length > 0), + }; + + await onSubmit?.(transformedData).then((success) => { + if (success) props.onOpenChange?.(false); + }); + }; + + return ( + + + + create customer + +
+ +
+ Account information + + + Name + + + + + Account email + + + + + + Account Phone Number + + ( + + )} + /> + + + + Description + +