@manta-eu/react 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/components/button/index.d.ts +1 -0
  2. package/dist/components/button/index.d.ts.map +1 -1
  3. package/dist/components/button/index.js +17 -1
  4. package/dist/components/button/index.js.map +1 -1
  5. package/dist/components/modal/index.d.ts +4 -0
  6. package/dist/components/modal/index.d.ts.map +1 -1
  7. package/dist/components/modal/index.js +11 -3
  8. package/dist/components/modal/index.js.map +1 -1
  9. package/dist/components/sheet/index.d.ts +13 -19
  10. package/dist/components/sheet/index.d.ts.map +1 -1
  11. package/dist/components/sheet/index.js +8 -2
  12. package/dist/components/sheet/index.js.map +1 -1
  13. package/dist/components/table/index.d.ts +7 -1
  14. package/dist/components/table/index.d.ts.map +1 -1
  15. package/dist/components/table/index.js +18 -12
  16. package/dist/components/table/index.js.map +1 -1
  17. package/dist/globals.css +1526 -2772
  18. package/dist/hooks/use-connection-problems.d.ts.map +1 -1
  19. package/dist/hooks/useValidateActionPlan.d.ts.map +1 -1
  20. package/dist/routes/integrations/components/connect-forms/MetaAdsConnectForm.d.ts +1 -1
  21. package/dist/routes/integrations/components/connect-forms/MetaAdsConnectForm.d.ts.map +1 -1
  22. package/dist/routes/integrations/components/connect-forms/MetaAdsConnectForm.js +64 -11
  23. package/dist/routes/integrations/components/connect-forms/MetaAdsConnectForm.js.map +1 -1
  24. package/dist/routes/integrations/components/connect-forms/OAuthSetupConnectForm.d.ts.map +1 -1
  25. package/dist/routes/integrations/components/connect-forms/OAuthSetupConnectForm.js +8 -2
  26. package/dist/routes/integrations/components/connect-forms/OAuthSetupConnectForm.js.map +1 -1
  27. package/dist/routes/integrations/components/connect-forms/meta-page-tree.d.ts +5 -0
  28. package/dist/routes/integrations/components/connect-forms/meta-page-tree.d.ts.map +1 -0
  29. package/dist/routes/integrations/components/connect-forms/meta-page-tree.js +16 -0
  30. package/dist/routes/integrations/components/connect-forms/meta-page-tree.js.map +1 -0
  31. package/dist/routes/integrations/components/connect-forms/oauth-setup-phase.d.ts +2 -1
  32. package/dist/routes/integrations/components/connect-forms/oauth-setup-phase.d.ts.map +1 -1
  33. package/dist/routes/integrations/components/connect-forms/oauth-setup-phase.js +4 -1
  34. package/dist/routes/integrations/components/connect-forms/oauth-setup-phase.js.map +1 -1
  35. package/dist/routes/integrations/connection-view.d.ts.map +1 -1
  36. package/dist/routes/integrations/connection-view.js +1 -0
  37. package/dist/routes/integrations/connection-view.js.map +1 -1
  38. package/package.json +15 -15
  39. package/src/components/button/index.tsx +17 -1
  40. package/src/components/modal/index.tsx +17 -2
  41. package/src/components/sheet/index.tsx +29 -6
  42. package/src/components/table/index.tsx +42 -12
  43. package/src/routes/integrations/components/connect-forms/MetaAdsConnectForm.tsx +131 -15
  44. package/src/routes/integrations/components/connect-forms/OAuthSetupConnectForm.tsx +8 -2
  45. package/src/routes/integrations/components/connect-forms/meta-page-tree.ts +17 -0
  46. package/src/routes/integrations/components/connect-forms/oauth-setup-phase.ts +6 -0
  47. package/src/routes/integrations/connection-view.ts +1 -0
@@ -1,5 +1,6 @@
1
1
  import { Slot } from "@radix-ui/react-slot";
2
2
  import { Loader2 } from "lucide-react";
3
+ import { Children } from "react";
3
4
  import type { ComponentProps, ComponentPropsWithoutRef, ComponentType, ElementType, ReactNode, Ref } from "react";
4
5
  import { tv } from "tailwind-variants";
5
6
  import { cn, type ClassProp, type VariantPropsOf } from "#style";
@@ -8,6 +9,21 @@ function Loader(props: ComponentProps<typeof Loader2>) {
8
9
  return <Loader2 {...props} className={cn("size-4 animate-spin text-inherit", props.className)} />;
9
10
  }
10
11
 
12
+ function isText(child: unknown): child is string | number {
13
+ return typeof child === "string" || typeof child === "number";
14
+ }
15
+
16
+ // Text needs an element around it, or the loading state's `[&>*]:invisible` leaves it under the spinner.
17
+ export function wrapTextChildren(children: ReactNode): ReactNode[] {
18
+ const parts = Children.toArray(children);
19
+ return parts.flatMap<ReactNode>((child, index) => {
20
+ if (!isText(child)) return [child];
21
+ if (index > 0 && isText(parts[index - 1])) return [];
22
+ const end = parts.findIndex((part, at) => at > index && !isText(part));
23
+ return [<span key={`text-${index}`}>{parts.slice(index, end === -1 ? undefined : end).join("")}</span>];
24
+ });
25
+ }
26
+
11
27
  export const buttonConfig = {
12
28
  base: [
13
29
  "inline-flex items-center justify-center text-center gap-1.5 transition-[color_150ms_ease,transform_100ms_ease-out] motion-reduce:transition-none",
@@ -165,7 +181,7 @@ export function Button(props: ButtonProps) {
165
181
  {LeftIconComponent && <LeftIconComponent />}
166
182
  {label && <span>{label}</span>}
167
183
  {RightIconComponent && <RightIconComponent />}
168
- {children}
184
+ {wrapTextChildren(children)}
169
185
  {isLoading && (
170
186
  <span className="absolute inset-0 flex items-center justify-center" data-loader-wrapper aria-hidden="true">
171
187
  <Loader data-loader />
@@ -48,16 +48,30 @@ export const DialogOverlay = forwardRef<
48
48
  );
49
49
  });
50
50
 
51
+ export const dialogSizes = ["sm", "md", "lg", "xl", "2xl"] as const;
52
+
53
+ export type DialogSize = (typeof dialogSizes)[number];
54
+
55
+ const dialogSizeClasses: Record<DialogSize, string> = {
56
+ sm: "max-w-[440px]",
57
+ md: "max-w-[540px]",
58
+ lg: "max-w-[600px]",
59
+ xl: "max-w-[920px]",
60
+ "2xl": "max-w-[1240px]",
61
+ };
62
+
51
63
  type DialogContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
52
64
  showCloseButton?: boolean;
53
65
  overlayClassName?: string;
54
66
  /** Below `sm`, fill the viewport as a sheet instead of floating as a centered card. */
55
67
  fullScreenOnMobile?: boolean;
68
+ /** Width preset; without one the dialog keeps its 700px default. */
69
+ size?: DialogSize;
56
70
  };
57
71
 
58
72
  export const DialogContent = forwardRef<ComponentRef<typeof DialogPrimitive.Content>, DialogContentProps>(
59
73
  function DialogContent(
60
- { className, children, showCloseButton = true, overlayClassName, fullScreenOnMobile = false, ...props },
74
+ { className, children, showCloseButton = true, overlayClassName, fullScreenOnMobile = false, size, ...props },
61
75
  ref,
62
76
  ) {
63
77
  return (
@@ -73,6 +87,7 @@ export const DialogContent = forwardRef<ComponentRef<typeof DialogPrimitive.Cont
73
87
  "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
74
88
  "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:pointer-events-none",
75
89
  "motion-reduce:animate-none",
90
+ size !== undefined && dialogSizeClasses[size],
76
91
  fullScreenOnMobile && [
77
92
  "max-sm:top-0! max-sm:left-0! max-sm:translate-x-0! max-sm:translate-y-0!",
78
93
  "max-sm:h-dvh! max-sm:max-h-dvh! max-sm:w-full! max-sm:max-w-none!",
@@ -88,7 +103,7 @@ export const DialogContent = forwardRef<ComponentRef<typeof DialogPrimitive.Cont
88
103
  data-slot="dialog-close"
89
104
  className={cn(
90
105
  "ring-offset-card focus:ring-active-border/50 data-[state=open]:bg-accent",
91
- "absolute top-4 right-4 rounded-sm-v4 opacity-70 data-[state=open]:text-muted-foreground",
106
+ "absolute top-4 right-4 rounded-sm-v4 opacity-70 text-muted-foreground",
92
107
  "hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none",
93
108
  "transition-opacity motion-reduce:transition-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-5",
94
109
  )}
@@ -64,22 +64,45 @@ export const sheetContentConfig = {
64
64
  } as const,
65
65
  };
66
66
 
67
+ export const sheetSizes = ["sm", "md", "lg"] as const;
68
+
69
+ export type SheetSize = (typeof sheetSizes)[number];
70
+
71
+ const sheetSizeClasses: Record<SheetSize, string> = {
72
+ sm: "w-[420px] max-w-full",
73
+ md: "w-[480px] max-w-full",
74
+ lg: "w-[560px] max-w-full",
75
+ };
76
+
67
77
  const sheetContentVariants = tv(sheetContentConfig);
68
78
 
69
- export type SheetContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
70
- VariantPropsOf<typeof sheetContentConfig> & {
71
- showClose?: boolean;
72
- };
79
+ export type SheetSide = NonNullable<VariantPropsOf<typeof sheetContentConfig>["side"]>;
80
+
81
+ export type SheetContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
82
+ showClose?: boolean;
83
+ } & (
84
+ | {
85
+ side?: Extract<SheetSide, "left" | "right">;
86
+ /** Width preset; without one the sheet keeps its 400px default. */
87
+ size?: SheetSize;
88
+ }
89
+ | { side: Exclude<SheetSide, "left" | "right">; size?: never }
90
+ );
73
91
 
74
92
  export const SheetContent = React.forwardRef<React.ComponentRef<typeof DialogPrimitive.Content>, SheetContentProps>(
75
- function SheetContent({ side = "right", className, children, showClose = true, ...props }, ref) {
93
+ function SheetContent({ side = "right", size, className, children, showClose = true, ...props }, ref) {
76
94
  return (
77
95
  <SheetPortal>
78
96
  <SheetOverlay />
79
97
  <DialogPrimitive.Content
80
98
  ref={ref}
81
99
  data-slot="sheet-content"
82
- className={cn(sheetContentVariants({ side }), side === "center" ? "gap-6 p-8" : "gap-6 p-6", className)}
100
+ className={cn(
101
+ sheetContentVariants({ side }),
102
+ size !== undefined && sheetSizeClasses[size],
103
+ side === "center" ? "gap-6 p-8" : "gap-6 p-6",
104
+ className,
105
+ )}
83
106
  {...props}
84
107
  >
85
108
  {children}
@@ -4,7 +4,7 @@ import type { Cell, Column, ColumnDef, Row, SortingState, Table as TableInstance
4
4
  import { flexRender, getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
5
5
  import { ChevronDown, ChevronsUpDown, ChevronUp } from "lucide-react";
6
6
  import type { ComponentProps, HTMLAttributes, KeyboardEvent, ReactNode, RefObject } from "react";
7
- import { useMemo, useState } from "react";
7
+ import { createContext, useContext, useMemo, useState } from "react";
8
8
  import { match } from "ts-pattern";
9
9
  import { getCommonPinnedColumnStyles } from "./table-utils";
10
10
  import { Button } from "#components/button/index";
@@ -18,11 +18,26 @@ export { getCommonPinnedColumnStyles, getPinnedColumnInfo, type CombinedSortOpti
18
18
  export { useMaxTableRows } from "./use-max-table-rows";
19
19
  export { useTableState, type UseTableStateOptions } from "./use-table-state";
20
20
 
21
- export function Table({ className, ...props }: ComponentProps<"table">) {
21
+ export type TableSize = "base" | "sm";
22
+
23
+ const TableSizeContext = createContext<TableSize>("base");
24
+
25
+ export type TableProps = ComponentProps<"table"> & {
26
+ size?: TableSize;
27
+ };
28
+
29
+ export function Table({ className, size = "base", ...props }: TableProps) {
22
30
  return (
23
- <div data-slot="table-container" className="relative w-full overflow-x-auto">
24
- <table data-slot="table" className={cn("w-full caption-bottom typography-small", className)} {...props} />
25
- </div>
31
+ <TableSizeContext.Provider value={size}>
32
+ <div data-slot="table-container" className="relative w-full overflow-x-auto">
33
+ <table
34
+ data-slot="table"
35
+ data-size={size}
36
+ className={cn("w-full caption-bottom typography-small", className)}
37
+ {...props}
38
+ />
39
+ </div>
40
+ </TableSizeContext.Provider>
26
41
  );
27
42
  }
28
43
 
@@ -45,11 +60,13 @@ export function TableFooter({ className, ...props }: ComponentProps<"tfoot">) {
45
60
  }
46
61
 
47
62
  export function TableRow({ className, ...props }: ComponentProps<"tr">) {
63
+ const size = useContext(TableSizeContext);
48
64
  return (
49
65
  <tr
50
66
  data-slot="table-row"
51
67
  className={cn(
52
68
  "bg-card hover:bg-muted data-[state=selected]:bg-muted border-b transition-colors text-default-foreground [&>td[data-is-pinned]]:bg-card [&>td[data-is-pinned]]:transition-colors [&>td[data-is-pinned]]:group-hover:bg-muted",
69
+ size === "sm" && "border-default-border",
53
70
  className,
54
71
  )}
55
72
  {...props}
@@ -58,11 +75,13 @@ export function TableRow({ className, ...props }: ComponentProps<"tr">) {
58
75
  }
59
76
 
60
77
  export function TableHead({ className, ...props }: ComponentProps<"th">) {
78
+ const size = useContext(TableSizeContext);
61
79
  return (
62
80
  <th
63
81
  data-slot="table-head"
64
82
  className={cn(
65
- "bg-faint text-muted-foreground h-10 px-2 text-left align-middle typography-small-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] data-is-pinned:bg-faint",
83
+ "bg-faint text-muted-foreground h-10 px-2 text-left align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] data-is-pinned:bg-faint",
84
+ size === "sm" ? "h-9 px-4 py-2.5 typography-tiny-medium whitespace-normal" : "typography-small-medium",
66
85
  className,
67
86
  )}
68
87
  {...props}
@@ -71,11 +90,13 @@ export function TableHead({ className, ...props }: ComponentProps<"th">) {
71
90
  }
72
91
 
73
92
  export function TableCell({ className, ...props }: ComponentProps<"td">) {
93
+ const size = useContext(TableSizeContext);
74
94
  return (
75
95
  <td
76
96
  data-slot="table-cell"
77
97
  className={cn(
78
98
  "px-4 not-first:px-3 py-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
99
+ size === "sm" && "not-first:px-4 py-3 align-top text-[0.8125rem] leading-5 whitespace-normal break-words",
79
100
  className,
80
101
  )}
81
102
  {...props}
@@ -117,13 +138,14 @@ declare module "@tanstack/react-table" {
117
138
 
118
139
  export function CustomTableCell<TData, TValue>({ cell }: { cell: Cell<TData, TValue> }) {
119
140
  const columnType = cell.column.columnDef.meta?.type;
141
+ const size = useContext(TableSizeContext);
120
142
 
121
143
  const pinningStyles = getCommonPinnedColumnStyles(cell.column);
122
144
 
123
145
  return (
124
146
  <TableCell
125
147
  className={cn(
126
- "h-12",
148
+ size === "base" && "h-12",
127
149
  cell.column.columnDef.meta?.wide ? "max-w-none" : "max-w-56",
128
150
  cell.column.columnDef.meta?.className,
129
151
  )}
@@ -159,7 +181,7 @@ export function ColumnHeader<TData, TValue>({ column, children }: TColumnHeaderP
159
181
  const isSorted = column.getIsSorted();
160
182
 
161
183
  const className = cn(
162
- "typography-caption-medium flex gap-0.5 text-muted-foreground items-center group/sort-header",
184
+ "flex gap-0.5 text-muted-foreground items-center group/sort-header",
163
185
  isNumberColumn && "justify-end",
164
186
  column.getCanSort() && "cursor-pointer",
165
187
  );
@@ -203,6 +225,7 @@ export type DataTableBaseProps<TData> = HTMLAttributes<HTMLDivElement> & {
203
225
  renderRow?: (row: Row<TData>) => ReactNode;
204
226
  rowClassName?: (row: Row<TData>) => string | undefined;
205
227
  isRowClickable?: (row: Row<TData>) => boolean;
228
+ size?: TableSize;
206
229
  };
207
230
 
208
231
  export type DataTableWithRowClick<TData> = DataTableBaseProps<TData> & {
@@ -258,6 +281,7 @@ export function DataTable<TData>(props: DataTableProps<TData>) {
258
281
  renderRow,
259
282
  rowClassName,
260
283
  isRowClickable,
284
+ size = "base",
261
285
  ...restProps
262
286
  } = props;
263
287
 
@@ -265,7 +289,9 @@ export function DataTable<TData>(props: DataTableProps<TData>) {
265
289
 
266
290
  const hasClickHandler = !!onRowClick || !!rowHref;
267
291
 
268
- const tableBodyRowClassName = cn("group border-muted-border bg-card py-2 px-4", hasClickHandler && "cursor-pointer");
292
+ const rowBorderClassName = size === "base" && "border-muted-border";
293
+
294
+ const tableBodyRowClassName = cn("group bg-card py-2 px-4", rowBorderClassName, hasClickHandler && "cursor-pointer");
269
295
 
270
296
  const handleRowClick = (row: Row<TData>) => {
271
297
  if (onRowClick) {
@@ -329,10 +355,10 @@ export function DataTable<TData>(props: DataTableProps<TData>) {
329
355
 
330
356
  return (
331
357
  <div className={cn("grid gap-2.5", className)} {...restProps} ref={ref}>
332
- <Table>
358
+ <Table size={size}>
333
359
  <TableHeader className={headerClassName}>
334
360
  {table.getHeaderGroups().map((headerGroup) => (
335
- <TableRow key={headerGroup.id} className="border-muted-border">
361
+ <TableRow key={headerGroup.id} className={cn(rowBorderClassName)}>
336
362
  {headerGroup.headers.map((header) => {
337
363
  const headerContent = header.isPlaceholder
338
364
  ? null
@@ -343,7 +369,10 @@ export function DataTable<TData>(props: DataTableProps<TData>) {
343
369
  return (
344
370
  <TableHead
345
371
  key={header.id}
346
- className={cn("h-13 px-4 not-first:px-3 py-2", header.column.columnDef.meta?.className)}
372
+ className={cn(
373
+ size === "base" && "h-13 px-4 not-first:px-3 py-2",
374
+ header.column.columnDef.meta?.className,
375
+ )}
347
376
  data-is-pinned={header.column.getIsPinned() ? true : undefined}
348
377
  style={pinningStyles}
349
378
  ref={(thElement) => columnSizingHandler(thElement, table, header.column)}
@@ -390,6 +419,7 @@ export type SortableTableBaseProps<TRow> = {
390
419
  noResultsMessage?: string;
391
420
  className?: string;
392
421
  isLoading?: boolean;
422
+ size?: TableSize;
393
423
  };
394
424
 
395
425
  export type SortableTableProps<TRow> =
@@ -3,8 +3,11 @@ import {
3
3
  GQLMetaSetupStateEnum,
4
4
  useConnectedAccountsQuery,
5
5
  useMetaSetupConnectMutation,
6
+ useMetaSetupIncludePagesMutation,
6
7
  useMetaSetupProgressQuery,
7
8
  type GQLMetaAdAccount,
9
+ type GQLMetaPage,
10
+ type GQLMetaSetupProgress,
8
11
  } from "@manta-eu/client";
9
12
  import { parseError } from "@manta-eu/client/shared/error-utils";
10
13
  import { isPresent } from "@manta-eu/lib/present";
@@ -15,6 +18,7 @@ import { toast } from "sonner";
15
18
  import { match } from "ts-pattern";
16
19
  import { ConnectFormHeader } from "./ConnectFormHeader";
17
20
  import { ImportStartedNotice } from "./ImportStartedNotice";
21
+ import { metaPageTreeItems, picksAPage } from "./meta-page-tree";
18
22
  import { OAuthConnectLink } from "./OAuthConnectLink";
19
23
  import { unavailableReasonLabel } from "./setup-account-unavailable-reason";
20
24
  import type { ConnectFormProps, ImportNotice } from "./types";
@@ -24,12 +28,22 @@ import { Banner } from "#components/banner/index";
24
28
  import { Button } from "#components/button/index";
25
29
  import { Loader } from "#components/loader/index";
26
30
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "#components/select/index";
31
+ import { TreeMultiSelect } from "#components/tree-multi-select/index";
27
32
  import { cn } from "#lib/utils";
28
33
  import { useMantaClient } from "#providers/index";
29
34
 
30
35
  const pollIntervalMs = 3000;
31
36
 
32
- type Phase = "idle" | "awaitingOAuth" | "loadingAccounts" | "pickingAccount" | "submitting" | "done" | "failed";
37
+ type Phase =
38
+ | "idle"
39
+ | "awaitingOAuth"
40
+ | "loadingAccounts"
41
+ | "pickingAccount"
42
+ | "loadingPages"
43
+ | "pickingPages"
44
+ | "submitting"
45
+ | "done"
46
+ | "failed";
33
47
 
34
48
  type DerivePhaseInput = {
35
49
  states: GQLMetaSetupStateEnum[] | undefined;
@@ -42,6 +56,8 @@ const statePriority: readonly GQLMetaSetupStateEnum[] = [
42
56
  GQLMetaSetupStateEnum.FatallyFailed,
43
57
  GQLMetaSetupStateEnum.TimedOut,
44
58
  GQLMetaSetupStateEnum.ConnectingAccounts,
59
+ GQLMetaSetupStateEnum.SelectingPages,
60
+ GQLMetaSetupStateEnum.FetchingPages,
45
61
  GQLMetaSetupStateEnum.SelectingAccounts,
46
62
  GQLMetaSetupStateEnum.FetchingAccounts,
47
63
  GQLMetaSetupStateEnum.Setup,
@@ -53,7 +69,14 @@ const terminalStates: readonly GQLMetaSetupStateEnum[] = [
53
69
  GQLMetaSetupStateEnum.TimedOut,
54
70
  ];
55
71
 
56
- const activePhases: readonly Phase[] = ["awaitingOAuth", "loadingAccounts", "pickingAccount", "submitting"];
72
+ const activePhases: readonly Phase[] = [
73
+ "awaitingOAuth",
74
+ "loadingAccounts",
75
+ "pickingAccount",
76
+ "loadingPages",
77
+ "pickingPages",
78
+ "submitting",
79
+ ];
57
80
 
58
81
  export function derivePhase({ states, hasStartedOAuth, hasSeenActiveState }: DerivePhaseInput): Phase {
59
82
  const defaultPhase: Phase = hasStartedOAuth ? "awaitingOAuth" : "idle";
@@ -64,6 +87,8 @@ export function derivePhase({ states, hasStartedOAuth, hasSeenActiveState }: Der
64
87
  .with(GQLMetaSetupStateEnum.Finished, () => "done")
65
88
  .with(GQLMetaSetupStateEnum.FatallyFailed, GQLMetaSetupStateEnum.TimedOut, () => "failed")
66
89
  .with(GQLMetaSetupStateEnum.ConnectingAccounts, () => "submitting")
90
+ .with(GQLMetaSetupStateEnum.SelectingPages, () => "pickingPages")
91
+ .with(GQLMetaSetupStateEnum.FetchingPages, () => "loadingPages")
67
92
  .with(GQLMetaSetupStateEnum.SelectingAccounts, () => "pickingAccount")
68
93
  .with(GQLMetaSetupStateEnum.FetchingAccounts, GQLMetaSetupStateEnum.Setup, () => "loadingAccounts")
69
94
  .otherwise(() => defaultPhase);
@@ -114,11 +139,20 @@ export function MetaAdsConnectForm(props: ConnectFormProps) {
114
139
  const [hasStartedOAuth, setHasStartedOAuth] = useState(resumedFromOAuth);
115
140
  const [hasSeenActiveState, setHasSeenActiveState] = useState(resumedFromOAuth);
116
141
  const [selectedAccountId, setSelectedAccountId] = useState<string>("");
142
+ const [selectedPageIds, setSelectedPageIds] = useState<ReadonlySet<string>>(new Set());
117
143
  const hasDismissedRef = useRef(false);
118
144
 
119
145
  const progressQuery = useMetaSetupProgressQuery(mantaClient, undefined, { staleTime: 0 });
120
146
  const progress = progressQuery.data?.metaSetupProgress;
121
- const phase = derivePhase({ states: progress?.state, hasStartedOAuth, hasSeenActiveState });
147
+ const connectMutation = useMetaSetupConnectMutation(mantaClient);
148
+ const includePagesMutation = useMetaSetupIncludePagesMutation(mantaClient);
149
+ // Progress reads the old step until a mutation's step has run, so a pending one shows where it is heading.
150
+ const phase = connectMutation.isPending
151
+ ? "loadingPages"
152
+ : includePagesMutation.isPending
153
+ ? "submitting"
154
+ : derivePhase({ states: progress?.state, hasStartedOAuth, hasSeenActiveState });
155
+ const pages = progress?.pages ?? [];
122
156
 
123
157
  useWindowFocusPoll(progressQuery.refetch, { enabled: activePhases.includes(phase), intervalMs: pollIntervalMs });
124
158
 
@@ -139,8 +173,6 @@ export function MetaAdsConnectForm(props: ConnectFormProps) {
139
173
  );
140
174
  const reconnectableExternalIds = useReconnectableExternalIds(GQLIntegration.Meta);
141
175
 
142
- const connectMutation = useMetaSetupConnectMutation(mantaClient);
143
-
144
176
  useEffect(() => {
145
177
  if (phase !== "done" || hasDismissedRef.current) return;
146
178
  hasDismissedRef.current = true;
@@ -149,10 +181,12 @@ export function MetaAdsConnectForm(props: ConnectFormProps) {
149
181
  if (!importNotice) onDismiss();
150
182
  }, [phase, onChanged, onDismiss, importNotice]);
151
183
 
184
+ function setProgress(metaSetupProgress: GQLMetaSetupProgress) {
185
+ queryClient.setQueryData(useMetaSetupProgressQuery.getKey(), { metaSetupProgress });
186
+ }
187
+
152
188
  function resetProgressCache() {
153
- queryClient.setQueryData(useMetaSetupProgressQuery.getKey(), {
154
- metaSetupProgress: { state: [], accounts: null, lastError: null },
155
- });
189
+ setProgress({ state: [], accounts: null, pages: null, lastError: null });
156
190
  }
157
191
 
158
192
  function handleOpened() {
@@ -164,16 +198,34 @@ export function MetaAdsConnectForm(props: ConnectFormProps) {
164
198
  async function handleSubmit() {
165
199
  if (!selectedAccountId) return;
166
200
  try {
167
- await connectMutation.mutateAsync({ accountIds: [selectedAccountId] });
168
- await progressQuery.refetch();
201
+ setProgress((await connectMutation.mutateAsync({ accountIds: [selectedAccountId] })).metaSetupConnect);
169
202
  } catch (err) {
170
203
  const info = parseError(err, "Failed to connect Meta");
171
204
  toast.error(info.message, { description: info.description });
172
205
  }
173
206
  }
174
207
 
208
+ async function handleIncludePages() {
209
+ try {
210
+ setProgress((await includePagesMutation.mutateAsync({ pageIds: [...selectedPageIds] })).metaSetupIncludePages);
211
+ } catch (err) {
212
+ const info = parseError(err, "Failed to connect Meta");
213
+ toast.error(info.message, { description: info.description });
214
+ }
215
+ }
216
+
217
+ function togglePage(id: string, checked: boolean) {
218
+ setSelectedPageIds((current) => {
219
+ const next = new Set(current);
220
+ if (checked) next.add(id);
221
+ else next.delete(id);
222
+ return next;
223
+ });
224
+ }
225
+
175
226
  function handleRetry() {
176
227
  setSelectedAccountId("");
228
+ setSelectedPageIds(new Set());
177
229
  hasDismissedRef.current = false;
178
230
  resetProgressCache();
179
231
  setHasStartedOAuth(false);
@@ -198,6 +250,10 @@ export function MetaAdsConnectForm(props: ConnectFormProps) {
198
250
  onSelect={setSelectedAccountId}
199
251
  />
200
252
  ))
253
+ .with("loadingPages", () => <LoadingPagesView />)
254
+ .with("pickingPages", () => (
255
+ <PickingPagesView pages={pages} selectedIds={selectedPageIds} onToggle={togglePage} />
256
+ ))
201
257
  .with("submitting", () => <SubmittingView />)
202
258
  .with("done", () => <DoneView providerName={provider.name} importNotice={importNotice} />)
203
259
  .with("failed", () => (
@@ -220,12 +276,16 @@ export function MetaAdsConnectForm(props: ConnectFormProps) {
220
276
  <OAuthConnectLink href="/oauth/meta/start" label="Continue with Meta" onOpened={handleOpened} />
221
277
  )}
222
278
  {phase === "pickingAccount" && (
279
+ <Button onClick={() => void handleSubmit()} disabled={!selectedAccountId}>
280
+ Continue
281
+ </Button>
282
+ )}
283
+ {phase === "pickingPages" && (
223
284
  <Button
224
- onClick={() => void handleSubmit()}
225
- disabled={!selectedAccountId}
226
- isLoading={connectMutation.isPending}
285
+ onClick={() => void handleIncludePages()}
286
+ disabled={isPresent(pages) && !picksAPage(pages, selectedPageIds)}
227
287
  >
228
- Connect account
288
+ {isPresent(pages) ? "Connect" : "Connect without a Page"}
229
289
  </Button>
230
290
  )}
231
291
  {phase === "done" && importNotice && <Button onClick={onDismiss}>Done</Button>}
@@ -348,12 +408,68 @@ function PickingAccountView(props: PickingAccountViewProps) {
348
408
  );
349
409
  }
350
410
 
411
+ function LoadingPagesView() {
412
+ return (
413
+ <div className="grid gap-4">
414
+ <div className="grid gap-1">
415
+ <h2 className="typography-large-body-semibold text-default-foreground">Loading your Facebook Pages</h2>
416
+ <p className="typography-caption text-muted-foreground">
417
+ The Pages this ad account can advertise as, and their Instagram accounts.
418
+ </p>
419
+ </div>
420
+ <div
421
+ className="flex items-center gap-2 text-muted-foreground typography-caption"
422
+ role="status"
423
+ aria-live="polite"
424
+ >
425
+ <Loader className="size-4" aria-hidden /> Fetching Pages…
426
+ </div>
427
+ </div>
428
+ );
429
+ }
430
+
431
+ type PickingPagesViewProps = {
432
+ pages: GQLMetaPage[];
433
+ selectedIds: ReadonlySet<string>;
434
+ onToggle: (id: string, checked: boolean) => void;
435
+ };
436
+
437
+ function PickingPagesView(props: PickingPagesViewProps) {
438
+ const { pages, selectedIds, onToggle } = props;
439
+
440
+ if (!isPresent(pages)) {
441
+ return (
442
+ <Banner
443
+ type="warning"
444
+ label="This ad account can't advertise as any Facebook Page yet. Connect it anyway, and add a Page to it in Meta Business Suite before you run ads."
445
+ />
446
+ );
447
+ }
448
+
449
+ return (
450
+ <div className="grid gap-4">
451
+ <div className="grid gap-1">
452
+ <h2 className="typography-large-body-semibold text-default-foreground">Pick the Facebook Pages to use</h2>
453
+ <p className="typography-caption text-muted-foreground">
454
+ Manta runs ads only as the Pages and Instagram accounts you pick. Pick at least one Page.
455
+ </p>
456
+ </div>
457
+ <TreeMultiSelect
458
+ items={metaPageTreeItems(pages)}
459
+ selectedIds={selectedIds}
460
+ onToggle={onToggle}
461
+ searchPlaceholder="Search Pages..."
462
+ />
463
+ </div>
464
+ );
465
+ }
466
+
351
467
  function SubmittingView() {
352
468
  return (
353
469
  <div className="grid gap-4">
354
470
  <div className="grid gap-1">
355
471
  <h2 className="typography-large-body-semibold text-default-foreground">Connecting your account</h2>
356
- <p className="typography-caption text-muted-foreground">Hold tight — this usually takes a few seconds.</p>
472
+ <p className="typography-caption text-muted-foreground">This can take a minute. Keep this window open.</p>
357
473
  </div>
358
474
  <div
359
475
  className="flex items-center gap-2 text-muted-foreground typography-caption"
@@ -85,11 +85,12 @@ export function OAuthSetupConnectForm<Row extends SetupRow>(props: OAuthSetupCon
85
85
  const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set());
86
86
  const hasDismissedRef = useRef(false);
87
87
  const [connectionExpired, setConnectionExpired] = useState(false);
88
+ const [submitted, setSubmitted] = useState(false);
88
89
 
89
90
  const states = setup.progress?.state;
90
91
  const phase = connectionExpired
91
92
  ? "failed"
92
- : derivePhase({ states, hasStartedOAuth, hasSeenActiveState, resumesUnfinishedSetup });
93
+ : derivePhase({ states, hasStartedOAuth, hasSeenActiveState, resumesUnfinishedSetup, submitted });
93
94
 
94
95
  useWindowFocusPoll(setup.refetchProgress, { enabled: activePhases.includes(phase), intervalMs: pollIntervalMs });
95
96
 
@@ -114,6 +115,7 @@ export function OAuthSetupConnectForm<Row extends SetupRow>(props: OAuthSetupCon
114
115
 
115
116
  function handleOpened() {
116
117
  setConnectionExpired(false);
118
+ setSubmitted(false);
117
119
  setSelectedIds(new Set());
118
120
  setup.resetProgress();
119
121
  setHasSeenActiveState(false);
@@ -131,21 +133,25 @@ export function OAuthSetupConnectForm<Row extends SetupRow>(props: OAuthSetupCon
131
133
 
132
134
  async function handleSubmit() {
133
135
  if (selectedIds.size === 0) return;
136
+ setSubmitted(true);
134
137
  try {
135
138
  const result = await setup.connect([...selectedIds]);
136
139
  if (result.state.includes(GQLGoogleSetupStateEnum.NotFound)) {
140
+ setSubmitted(false);
137
141
  setConnectionExpired(true);
138
142
  setSelectedIds(new Set());
139
143
  return;
140
144
  }
141
145
  await setup.refetchProgress();
142
146
  } catch (err) {
147
+ setSubmitted(false);
143
148
  const info = parseError(err, `Failed to connect ${providerName}`);
144
149
  toast.error(info.message, { description: info.description });
145
150
  }
146
151
  }
147
152
 
148
153
  function handleRetry() {
154
+ setSubmitted(false);
149
155
  setSelectedIds(new Set());
150
156
  hasDismissedRef.current = false;
151
157
  setup.resetProgress();
@@ -343,7 +349,7 @@ function SubmittingView(props: CopyProps) {
343
349
  <div className="grid gap-4">
344
350
  <div className="grid gap-1">
345
351
  <h2 className="typography-large-body-semibold text-default-foreground">Connecting your {copy.entityPlural}</h2>
346
- <p className="typography-caption text-muted-foreground">Hold tight — this usually takes a few seconds.</p>
352
+ <p className="typography-caption text-muted-foreground">This can take a minute. Keep this window open.</p>
347
353
  </div>
348
354
  <StatusLine label="Provisioning…" />
349
355
  </div>
@@ -0,0 +1,17 @@
1
+ import { GQLMetaPageKind, type GQLMetaPage } from "@manta-eu/client";
2
+ import { buildTreeRows, type TreeSelectItem } from "#components/tree-multi-select/index";
3
+
4
+ // Each Instagram account sits under the Page it is linked to, and is picked on its own.
5
+ export function metaPageTreeItems(pages: readonly GQLMetaPage[]): TreeSelectItem[] {
6
+ return buildTreeRows(pages).map(({ node, depth }) => ({
7
+ id: node.id,
8
+ label: node.name ?? (node.kind === GQLMetaPageKind.Instagram ? `Instagram account ${node.id}` : node.id),
9
+ caption: node.kind === GQLMetaPageKind.Instagram ? "Instagram" : undefined,
10
+ selectable: true,
11
+ depth,
12
+ }));
13
+ }
14
+
15
+ export function picksAPage(pages: readonly GQLMetaPage[], selectedIds: ReadonlySet<string>): boolean {
16
+ return pages.some((page) => page.kind === GQLMetaPageKind.Page && selectedIds.has(page.id));
17
+ }
@@ -9,6 +9,8 @@ export type DerivePhaseInput = {
9
9
  hasSeenActiveState: boolean;
10
10
  // Off when the account is connected before its picker, so a leftover setup cannot stand in for a new sign-in.
11
11
  resumesUnfinishedSetup?: boolean;
12
+ // Connect was sent: progress reads the old state until the connect has run, so only an outcome ends it.
13
+ submitted?: boolean;
12
14
  };
13
15
 
14
16
  const statePriority: readonly GQLGoogleSetupStateEnum[] = [
@@ -34,10 +36,14 @@ export function derivePhase({
34
36
  hasStartedOAuth,
35
37
  hasSeenActiveState,
36
38
  resumesUnfinishedSetup = true,
39
+ submitted = false,
37
40
  }: DerivePhaseInput): Phase {
38
41
  const defaultPhase: Phase = hasStartedOAuth ? "awaitingOAuth" : "idle";
39
42
  if (!hasStartedOAuth && !resumesUnfinishedSetup) return defaultPhase;
40
43
  const dominant = statePriority.find((state) => states?.includes(state));
44
+ const settled =
45
+ states?.includes(GQLGoogleSetupStateEnum.NotFound) || (dominant !== undefined && terminalStates.includes(dominant));
46
+ if (submitted && !settled) return "submitting";
41
47
  if (dominant && terminalStates.includes(dominant) && !hasSeenActiveState) return defaultPhase;
42
48
  return match(dominant)
43
49
  .returnType<Phase>()