@oneclick.dev/cms-core-modules 0.0.70 → 0.0.72
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.
- package/dist/Acquisition-D_Qx7XRY.mjs +400 -0
- package/dist/Acquisition-_-cq972V.js +1 -0
- package/dist/AppointmentDetailsCard-CtK0Cj_O.js +1 -0
- package/dist/AppointmentDetailsCard-i4Ky8WnK.mjs +161 -0
- package/dist/AppointmentEditor-BeOtYV6u.mjs +196 -0
- package/dist/AppointmentEditor-CaTfbkIf.js +1 -0
- package/dist/AppointmentListTable-CQ3uXfa8.js +1 -0
- package/dist/AppointmentListTable-G5dIeCYj.mjs +159 -0
- package/dist/Audience-BdlrWkY-.mjs +489 -0
- package/dist/Audience-QnFQs93n.js +1 -0
- package/dist/Content-C-4Cdniz.js +1 -0
- package/dist/Content-DW6xvbUo.mjs +358 -0
- package/dist/ContentEditor-3JFlC9N1.js +313 -0
- package/dist/{ContentEditor-B7IYLH91.mjs → ContentEditor-7SDjf5Hk.mjs} +16535 -25527
- package/dist/CountryBreakdownCard-BUfJ-umQ.js +1 -0
- package/dist/CountryBreakdownCard-Dc_vZ9dJ.mjs +196 -0
- package/dist/{Create-D8NBEkJU.js → Create-CRn2sMHp.js} +1 -1
- package/dist/{Create-BDiyCtrp.mjs → Create-DtFzwxn_.mjs} +1 -1
- package/dist/DateFormatter-C9qfmQnP.js +1 -0
- package/dist/{DateFormatter-DAZP9a6L.mjs → DateFormatter-DfIwyrnF.mjs} +399 -258
- package/dist/{Detail-Cxd2VMOl.js → Detail-BcboL7NF.js} +1 -1
- package/dist/{Detail-CxUU6BpO.mjs → Detail-BwDnY42G.mjs} +1 -1
- package/dist/{Detail-DTHaYmyl.js → Detail-Bxi7lG9x.js} +1 -1
- package/dist/{Detail-BGD75ul4.mjs → Detail-CVgWHbMC.mjs} +22 -18
- package/dist/DeviceBreakdownCard-BJcQNo8a.mjs +159 -0
- package/dist/DeviceBreakdownCard-CsoK6JUO.js +1 -0
- package/dist/{Find-B4sAEx0B.mjs → Find-XPcJxeRH.mjs} +1 -1
- package/dist/{NewReservationDialog.vue_vue_type_script_setup_true_lang-B-ZhMrMu.js → NewReservationDialog.vue_vue_type_script_setup_true_lang-C5YczjNb.js} +1 -1
- package/dist/{NewReservationDialog.vue_vue_type_script_setup_true_lang-CJ7hh7ug.mjs → NewReservationDialog.vue_vue_type_script_setup_true_lang-Ds7z0qeH.mjs} +1 -1
- package/dist/{Overview-HvlDEgqi.mjs → Overview--XpvBbqz.mjs} +1 -1
- package/dist/Overview-B8OF-JJr.mjs +567 -0
- package/dist/Overview-DSU1Nh7B.js +1 -0
- package/dist/PeakHoursCard-BNl_U1r_.js +1 -0
- package/dist/PeakHoursCard-hV8PdGsf.mjs +182 -0
- package/dist/ProductDetailsCard-BwktdlIf.js +1 -0
- package/dist/ProductDetailsCard-CJBkZSER.mjs +121 -0
- package/dist/RealtimeCard-CRJHS64l.mjs +126 -0
- package/dist/RealtimeCard-CxtqQHee.js +1 -0
- package/dist/SearchTermsCard-DGRbo5ZP.js +1 -0
- package/dist/SearchTermsCard-Dwa-P7My.mjs +201 -0
- package/dist/SeoHealth-BqlYiSCj.js +1 -0
- package/dist/SeoHealth-DG004AsJ.mjs +523 -0
- package/dist/{TableView-C4NNrJpP.js → TableView-CU0a9ih2.js} +162 -199
- package/dist/{TableView-B5OyApnP.mjs → TableView-fYC7kB6j.mjs} +23309 -24320
- package/dist/TopPagesCard-Dv52to_J.mjs +159 -0
- package/dist/TopPagesCard-pZ-nAsaI.js +1 -0
- package/dist/TrafficSourcesCard-CBOPV1J1.js +1 -0
- package/dist/TrafficSourcesCard-CMhcj2k2.mjs +197 -0
- package/dist/VisitorStatsCard-BHn4oSnt.mjs +174 -0
- package/dist/VisitorStatsCard-tApB_7v8.js +1 -0
- package/dist/{agenda-CTG6N_Lt.mjs → agenda-XqvbrU72.mjs} +2 -2
- package/dist/{agenda-OSkAh0Oa.js → agenda-wNFDKn0G.js} +1 -1
- package/dist/array-CbATeQbk.js +1 -0
- package/dist/array-CvmZXzxF.mjs +108 -0
- package/dist/cms-core-modules.css +1 -1
- package/dist/{exceptions-CEXIFw47.mjs → exceptions-CLgeeUSy.mjs} +1 -1
- package/dist/{exceptions-BvMTqtof.js → exceptions-wEQEVzIs.js} +1 -1
- package/dist/floating-ui.dom-BO2Hr6mz.mjs +1292 -0
- package/dist/floating-ui.dom-CAr9LGoC.js +1 -0
- package/dist/{index-AmUV8LZQ.mjs → index-B-lVEpFX.mjs} +1 -1
- package/dist/index-BuzLn4Km.js +54 -0
- package/dist/index-C4YUVWzJ.js +75 -0
- package/dist/index-CMk3uhUt.mjs +3837 -0
- package/dist/{index-Uf7TwBuW.mjs → index-CrGjxSwa.mjs} +4 -4
- package/dist/index-D2DYuz8R.mjs +1090 -0
- package/dist/index-D3L8WAJI.js +58 -0
- package/dist/index-D7JBu6iT.mjs +34 -0
- package/dist/index-DQYBP8Js.js +158 -0
- package/dist/index-DjDCYQ_6.mjs +441 -0
- package/dist/index-DwHYfZl_.js +1 -0
- package/dist/{index-Biuv2zTx-DBCin9DT.mjs → index-N6w5-hDF-6k_ELHYC.mjs} +1234 -1234
- package/dist/index-N6w5-hDF-DCuxwPBX.js +88 -0
- package/dist/index-O_QNuTdG.js +35 -0
- package/dist/index-_2lRVt_k.mjs +2942 -0
- package/dist/index-p5Uqu8c2.mjs +1438 -0
- package/dist/index.cjs.js +1 -1
- package/dist/index.mjs +383 -86
- package/dist/{resources-DgPnEv76.mjs → resources-CFR_s89Z.mjs} +1 -1
- package/dist/src/appointments/chat-components/AppointmentDetailsCard.vue.d.ts +14 -0
- package/dist/src/appointments/chat-components/AppointmentEditor.vue.d.ts +14 -0
- package/dist/src/appointments/chat-components/AppointmentListTable.vue.d.ts +14 -0
- package/dist/src/appointments/index.d.ts +70 -0
- package/dist/src/appointments/server.d.ts +19 -0
- package/dist/src/appointments/tools.d.ts +56 -16
- package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Button.vue.d.ts +3 -1
- package/dist/src/contentManager/components/content-editor/tiptap-extensions/CustomClass.d.ts +14 -0
- package/dist/src/contentManager/components/content-editor/tiptap-extensions/NodeDragPointerEventsFix.d.ts +2 -0
- package/dist/src/contentManager/components/content-editor/tiptap-menus/BubbleMenuClassBuilder.vue.d.ts +19 -0
- package/dist/src/contentManager/components/content-editor/tiptap-menus/BubbleMenuLinkBuilder.vue.d.ts +19 -0
- package/dist/src/googleAnalytics/chat-components/CountryBreakdownCard.vue.d.ts +14 -0
- package/dist/src/googleAnalytics/chat-components/DeviceBreakdownCard.vue.d.ts +14 -0
- package/dist/src/googleAnalytics/chat-components/PeakHoursCard.vue.d.ts +14 -0
- package/dist/src/googleAnalytics/chat-components/RealtimeCard.vue.d.ts +14 -0
- package/dist/src/googleAnalytics/chat-components/SearchTermsCard.vue.d.ts +14 -0
- package/dist/src/googleAnalytics/chat-components/TopPagesCard.vue.d.ts +14 -0
- package/dist/src/googleAnalytics/chat-components/TrafficSourcesCard.vue.d.ts +14 -0
- package/dist/src/googleAnalytics/chat-components/VisitorStatsCard.vue.d.ts +14 -0
- package/dist/src/googleAnalytics/config.d.ts +39 -0
- package/dist/src/googleAnalytics/index.d.ts +116 -0
- package/dist/src/googleAnalytics/metadata.d.ts +2 -0
- package/dist/src/googleAnalytics/pages/Acquisition.vue.d.ts +2 -0
- package/dist/src/googleAnalytics/pages/Audience.vue.d.ts +2 -0
- package/dist/src/googleAnalytics/pages/Content.vue.d.ts +2 -0
- package/dist/src/googleAnalytics/pages/Overview.vue.d.ts +2 -0
- package/dist/src/googleAnalytics/pages/SeoHealth.vue.d.ts +2 -0
- package/dist/src/googleAnalytics/permissions.d.ts +1 -0
- package/dist/src/googleAnalytics/server.d.ts +35 -0
- package/dist/src/googleAnalytics/tools.d.ts +59 -0
- package/dist/src/index.d.ts +6 -5
- package/dist/src/mailTemplates/components/TemplateList.vue.d.ts +2 -0
- package/dist/src/mailTemplates/routes.d.ts +5 -0
- package/dist/src/mailTemplates/tools.d.ts +1 -0
- package/dist/src/productCollections/tools.d.ts +1 -0
- package/dist/src/products/chat-components/ProductDetailsCard.vue.d.ts +14 -0
- package/dist/src/products/index.d.ts +24 -2
- package/dist/src/products/server.d.ts +19 -0
- package/dist/src/products/tools.d.ts +21 -2
- package/dist/src/promoCodes/tools.d.ts +1 -0
- package/dist/src/server-handlers.d.ts +9 -0
- package/dist/src/table/tools.d.ts +1 -25
- package/package.json +23 -21
- package/src/appointments/server.ts +195 -0
- package/src/appointments/tools.ts +61 -21
- package/src/googleAnalytics/server.ts +1188 -0
- package/src/googleAnalytics/tools.ts +158 -0
- package/src/mailTemplates/tools.ts +1 -0
- package/src/productCollections/tools.ts +1 -0
- package/src/products/server.ts +146 -0
- package/src/products/tools.ts +19 -0
- package/src/promoCodes/tools.ts +1 -0
- package/src/table/tools.ts +1 -25
- package/dist/ContentEditor-LOn09n7N.js +0 -307
- package/dist/DateFormatter-Bw-SCfUS.js +0 -1
- package/dist/floating-ui.dom-CBBY0Ism.js +0 -1
- package/dist/floating-ui.dom-xfqXu4GA.mjs +0 -1138
- package/dist/index-Biuv2zTx-DHiVlXFj.js +0 -88
- package/dist/index-HLGxDG-0.mjs +0 -7746
- package/dist/index-n2p9TTRq.js +0 -245
- package/dist/src/content-manager/components/content-editor/tiptap-extensions/DragHandle.d.ts +0 -76
- package/dist/src/content-manager/components/content-editor/tiptap-extensions/NodeDragPointerEventsFix.d.ts +0 -7
- package/dist/src/content-manager/components/content-editor/tiptap-extensions/extension-drag-handle/drag-handle-plugin.d.ts +0 -25
- package/dist/src/content-manager/components/content-editor/tiptap-extensions/extension-drag-handle/drag-handle.d.ts +0 -50
- package/dist/src/content-manager/components/content-editor/tiptap-extensions/extension-drag-handle/helpers/cloneElement.d.ts +0 -1
- package/dist/src/content-manager/components/content-editor/tiptap-extensions/extension-drag-handle/helpers/dragHandler.d.ts +0 -2
- package/dist/src/content-manager/components/content-editor/tiptap-extensions/extension-drag-handle/helpers/findNextElementFromCursor.d.ts +0 -17
- package/dist/src/content-manager/components/content-editor/tiptap-extensions/extension-drag-handle/helpers/getComputedStyle.d.ts +0 -1
- package/dist/src/content-manager/components/content-editor/tiptap-extensions/extension-drag-handle/helpers/getInnerCoords.d.ts +0 -5
- package/dist/src/content-manager/components/content-editor/tiptap-extensions/extension-drag-handle/helpers/getOuterNode.d.ts +0 -3
- package/dist/src/content-manager/components/content-editor/tiptap-extensions/extension-drag-handle/helpers/minMax.d.ts +0 -1
- package/dist/src/content-manager/components/content-editor/tiptap-extensions/extension-drag-handle/helpers/removeNode.d.ts +0 -1
- package/dist/src/content-manager/components/content-editor/tiptap-extensions/extension-drag-handle/index.d.ts +0 -4
- package/dist/src/mail-templates/tools.d.ts +0 -25
- package/dist/src/product-collections/tools.d.ts +0 -25
- package/dist/src/promo-codes/tools.d.ts +0 -25
- package/src/mail-templates/tools.ts +0 -25
- package/src/product-collections/tools.ts +0 -25
- package/src/promo-codes/tools.ts +0 -25
- /package/dist/src/{content-manager → contentManager}/components/components/ComponentDialog.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/components/ComponentGridItem.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/ApiDocs.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/ContentLayout.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/ContentSettings.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/ContentStructuredContentSections.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/Header.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/LivePreviewWrapper.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/Locales.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/NewSectionDialog.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/PageSettings.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/SEO.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/SidebarSectionWrapper.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/Status.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/Versions.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/code-examples.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Button.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Card.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Card.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Column.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Column.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/ComponentEditorNode.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Container.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Container.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Divider.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Divider.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/EmptySpace.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/EmptySpace.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Heading.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Heading.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Image.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Image.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/LockBlocks.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Map.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Map.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/OnlyContainerDocument.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Paragraph.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Paragraph.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Row.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Row.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/ScopedSelectAll.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Stack.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Stack.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/TrailingNodeInContainers.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Video.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/Video.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/extension-details/DetailsNodeView.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/extension-details/content/details-content.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/extension-details/content/index.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/extension-details/details.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/extension-details/helpers/findClosestVisibleNode.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/extension-details/helpers/isNodeVisible.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/extension-details/helpers/setGapCursor.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/extension-details/index.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/extension-details/summary/details-summary.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/extension-details/summary/index.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/helpers/NodeLabel.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/helpers/ResizeHandlers.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/helpers/TopMarginDragger.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/helpers/useContainerFocus.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-extensions/helpers/useFocus.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/BubbleMenuToggleButton.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/DefaultBubbleMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/ElementEditorMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/EmojiMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/NewNodeView.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/SlashMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/SlashMenuButton.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/element-editor-views/ButtonMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/element-editor-views/CardMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/element-editor-views/ColumnMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/element-editor-views/DividerMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/element-editor-views/EmptySpaceMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/element-editor-views/HeadingMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/element-editor-views/ImageMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/element-editor-views/MapMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/element-editor-views/RowMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/element-editor-views/StackMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/element-editor-views/TextMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/element-editor-views/VideoMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/content-editor/tiptap-menus/element-editor-views/index.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/overview/CollectionDialog.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/overview/CollectionsGridView.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/overview/ContentEntryContextMenu.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/overview/FilterPopover.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/overview/ListView.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/styling/ButtonStyleDialog.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/styling/CardStyleDialog.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/components/styling/SectionStyleDialog.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/config.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/index.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/index.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/metadata.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/pages/Components.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/pages/ContentEditor.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/pages/Entries.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/pages/GlobalStyling.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/pages/Overview.vue.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/permissions.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/routes.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/stores/useTableStore.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/tools.d.ts +0 -0
- /package/dist/src/{content-manager → contentManager}/types.d.ts +0 -0
- /package/dist/src/{mail-templates/components/TemplateList.vue.d.ts → googleAnalytics/index.vue.d.ts} +0 -0
- /package/dist/src/{mail-templates → googleAnalytics}/routes.d.ts +0 -0
- /package/dist/src/{mail-templates → mailTemplates}/config.d.ts +0 -0
- /package/dist/src/{mail-templates → mailTemplates}/index.d.ts +0 -0
- /package/dist/src/{mail-templates → mailTemplates}/index.vue.d.ts +0 -0
- /package/dist/src/{mail-templates → mailTemplates}/metadata.d.ts +0 -0
- /package/dist/src/{mail-templates → mailTemplates}/pages/Detail.vue.d.ts +0 -0
- /package/dist/src/{mail-templates → mailTemplates}/pages/Overview.vue.d.ts +0 -0
- /package/dist/src/{mail-templates → mailTemplates}/permissions.d.ts +0 -0
- /package/dist/src/{mail-templates → mailTemplates}/stores/useTableStore.d.ts +0 -0
- /package/dist/src/{mail-templates → mailTemplates}/types.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/components/detail/Main.vue.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/components/detail/SEO.vue.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/components/detail/Status.vue.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/components/overview/ListView.vue.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/composables/useTable.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/config.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/index.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/index.vue.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/metadata.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/pages/Detail.vue.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/pages/Overview.vue.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/permissions.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/routes.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/stores/useTableStore.d.ts +0 -0
- /package/dist/src/{product-collections → productCollections}/types.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/components/create/DateRange.vue.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/components/create/MultiCodes.vue.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/components/create/Note.vue.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/components/create/SingleCode.vue.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/components/create/TypeAndValue/TypeDetailDialog.vue.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/components/create/TypeAndValue/TypeManageDialog.vue.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/components/create/TypeAndValue/index.vue.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/config.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/index.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/index.vue.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/metadata.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/pages/Create.vue.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/pages/Detail.vue.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/pages/Find.vue.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/permissions.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/routes.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/stores/useTableStore.d.ts +0 -0
- /package/dist/src/{promo-codes → promoCodes}/types.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/components/detail/Condition.vue.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/components/detail/Countries.vue.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/components/detail/Example.vue.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/components/detail/Main.vue.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/components/overview/ListView.vue.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/composables/useTable.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/config.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/index.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/index.vue.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/metadata.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/pages/Detail.vue.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/pages/Overview.vue.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/permissions.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/routes.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/stores/useTableStore.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/tools.d.ts +0 -0
- /package/dist/src/{shipping-options → shippingOptions}/types.d.ts +0 -0
- /package/src/{content-manager → contentManager}/tools.ts +0 -0
- /package/src/{shipping-options → shippingOptions}/tools.ts +0 -0
|
@@ -0,0 +1,1188 @@
|
|
|
1
|
+
import { createRouter, defineEventHandler, createError, getQuery } from 'h3'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dependencies injected by the CMS when creating this handler.
|
|
5
|
+
*/
|
|
6
|
+
export interface ModuleHandlerDeps {
|
|
7
|
+
initFirebase: (event: any, integrationId: string) => Promise<any>
|
|
8
|
+
normalizeTimestamps: (data: any) => any
|
|
9
|
+
decrypt: (data: string) => string
|
|
10
|
+
getGoogleAccessToken: (config: { clientEmail: string; privateKey: string; projectId: string }, scopes: string[]) => Promise<string>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const GA_DATA_API = 'https://analyticsdata.googleapis.com/v1beta'
|
|
14
|
+
const GA_SCOPES = [
|
|
15
|
+
'https://www.googleapis.com/auth/analytics.readonly',
|
|
16
|
+
'https://www.googleapis.com/auth/webmasters.readonly',
|
|
17
|
+
]
|
|
18
|
+
const SEARCH_CONSOLE_API = 'https://searchconsole.googleapis.com/webmasters/v3'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Google Analytics module server handler factory.
|
|
22
|
+
*
|
|
23
|
+
* Routes (relative to `/api/v1/modules/:instanceId/`):
|
|
24
|
+
* GET /report → overview report with period comparison
|
|
25
|
+
* GET /realtime → real-time active users + pages + sources
|
|
26
|
+
* GET /top-pages → top pages by views
|
|
27
|
+
* GET /top-sources → top traffic sources
|
|
28
|
+
* GET /devices → device category breakdown
|
|
29
|
+
* GET /countries → top countries
|
|
30
|
+
* GET /acquisition/channels → channel grouping breakdown
|
|
31
|
+
* GET /acquisition/campaigns → campaign performance
|
|
32
|
+
* GET /acquisition/referrals → referral sources with landing pages
|
|
33
|
+
* GET /content/all-pages → all pages with deep metrics
|
|
34
|
+
* GET /content/landing-pages → top landing (entry) pages
|
|
35
|
+
* GET /content/exit-pages → top exit pages
|
|
36
|
+
* GET /audience/overview → new vs returning, engagement rate
|
|
37
|
+
* GET /audience/technology → browser + OS breakdown
|
|
38
|
+
* GET /audience/languages → language breakdown
|
|
39
|
+
* GET /audience/hours → sessions by hour-of-day (peak hours)
|
|
40
|
+
*/
|
|
41
|
+
export function createServerHandler(deps: ModuleHandlerDeps) {
|
|
42
|
+
const { decrypt, getGoogleAccessToken } = deps
|
|
43
|
+
const router = createRouter()
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the Google Service Account credentials + property ID from the module config.
|
|
47
|
+
*/
|
|
48
|
+
async function getGAContext(event: any) {
|
|
49
|
+
const { supabase, instanceId } = event.context.module
|
|
50
|
+
|
|
51
|
+
const { data: moduleRow, error } = await supabase
|
|
52
|
+
.from('project_modules')
|
|
53
|
+
.select('config')
|
|
54
|
+
.eq('id', instanceId)
|
|
55
|
+
.single()
|
|
56
|
+
|
|
57
|
+
if (error || !moduleRow?.config) {
|
|
58
|
+
throw createError({ statusCode: 500, statusMessage: 'Failed to load module config.' })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const config = moduleRow.config as Record<string, any>
|
|
62
|
+
const propertyId = config.propertyId as string
|
|
63
|
+
const integrationId = config.serviceAccount as string
|
|
64
|
+
const siteUrl = (config.siteUrl as string) || ''
|
|
65
|
+
|
|
66
|
+
if (!integrationId) {
|
|
67
|
+
throw createError({ statusCode: 400, statusMessage: 'No Google Service Account configured for this module.' })
|
|
68
|
+
}
|
|
69
|
+
if (!propertyId) {
|
|
70
|
+
throw createError({ statusCode: 400, statusMessage: 'No GA4 Property ID configured for this module.' })
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { data: integration, error: intError } = await supabase
|
|
74
|
+
.from('integrations')
|
|
75
|
+
.select('config')
|
|
76
|
+
.eq('id', integrationId)
|
|
77
|
+
.single()
|
|
78
|
+
|
|
79
|
+
let integrationConfig = integration?.config
|
|
80
|
+
if (intError || !integrationConfig) {
|
|
81
|
+
const { data: agencyInt, error: agencyErr } = await supabase
|
|
82
|
+
.from('agency_integrations')
|
|
83
|
+
.select('config')
|
|
84
|
+
.eq('id', integrationId)
|
|
85
|
+
.single()
|
|
86
|
+
|
|
87
|
+
if (agencyErr || !agencyInt?.config) {
|
|
88
|
+
throw createError({ statusCode: 500, statusMessage: 'Failed to load Google Service Account credentials.' })
|
|
89
|
+
}
|
|
90
|
+
integrationConfig = agencyInt.config
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const clientEmail = decrypt(integrationConfig.clientEmail)
|
|
94
|
+
const privateKey = decrypt(integrationConfig.privateKey)
|
|
95
|
+
const projectId = decrypt(integrationConfig.projectId)
|
|
96
|
+
|
|
97
|
+
const accessToken = await getGoogleAccessToken(
|
|
98
|
+
{ clientEmail, privateKey, projectId },
|
|
99
|
+
GA_SCOPES,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return { accessToken, propertyId, siteUrl }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Call the GA4 Data API's runReport endpoint.
|
|
107
|
+
*/
|
|
108
|
+
async function runReport(accessToken: string, propertyId: string, body: Record<string, any>) {
|
|
109
|
+
const res = await fetch(`${GA_DATA_API}/properties/${propertyId}:runReport`, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: {
|
|
112
|
+
Authorization: `Bearer ${accessToken}`,
|
|
113
|
+
'Content-Type': 'application/json',
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify(body),
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
if (!res.ok) {
|
|
119
|
+
const err = await res.json().catch(() => ({}))
|
|
120
|
+
console.error('GA4 runReport failed:', err)
|
|
121
|
+
throw createError({
|
|
122
|
+
statusCode: res.status,
|
|
123
|
+
statusMessage: err?.error?.message || 'GA4 API request failed',
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return res.json()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ═══════════════════════════════════════════════════════
|
|
131
|
+
// OVERVIEW ROUTES
|
|
132
|
+
// ═══════════════════════════════════════════════════════
|
|
133
|
+
|
|
134
|
+
// ── GET /report ──────────────────────────────────────
|
|
135
|
+
// Returns current period + previous period data for % change comparison
|
|
136
|
+
router.get(
|
|
137
|
+
'/report',
|
|
138
|
+
defineEventHandler(async (event) => {
|
|
139
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
140
|
+
const query = getQuery(event)
|
|
141
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
142
|
+
const endDate = (query.endDate as string) || 'today'
|
|
143
|
+
|
|
144
|
+
// Calculate previous period for comparison
|
|
145
|
+
const daysMatch = startDate.match(/^(\d+)daysAgo$/)
|
|
146
|
+
const days = daysMatch ? parseInt(daysMatch[1], 10) : 30
|
|
147
|
+
const prevStartDate = `${days * 2}daysAgo`
|
|
148
|
+
const prevEndDate = `${days + 1}daysAgo`
|
|
149
|
+
|
|
150
|
+
const reportMetrics = [
|
|
151
|
+
{ name: 'sessions' },
|
|
152
|
+
{ name: 'totalUsers' },
|
|
153
|
+
{ name: 'screenPageViews' },
|
|
154
|
+
{ name: 'bounceRate' },
|
|
155
|
+
{ name: 'averageSessionDuration' },
|
|
156
|
+
{ name: 'newUsers' },
|
|
157
|
+
{ name: 'engagementRate' },
|
|
158
|
+
{ name: 'sessionsPerUser' },
|
|
159
|
+
{ name: 'screenPageViewsPerSession' },
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
// Try with comparison period first; fall back to current-only if it fails
|
|
163
|
+
// (e.g. property is newer than the previous period range)
|
|
164
|
+
let raw: any
|
|
165
|
+
let hasComparison = true
|
|
166
|
+
try {
|
|
167
|
+
raw = await runReport(accessToken, propertyId, {
|
|
168
|
+
dateRanges: [
|
|
169
|
+
{ startDate, endDate, name: 'current' },
|
|
170
|
+
{ startDate: prevStartDate, endDate: prevEndDate, name: 'previous' },
|
|
171
|
+
],
|
|
172
|
+
dimensions: [{ name: 'date' }],
|
|
173
|
+
metrics: reportMetrics,
|
|
174
|
+
metricAggregations: ['TOTAL'],
|
|
175
|
+
orderBys: [{ dimension: { dimensionName: 'date' } }],
|
|
176
|
+
})
|
|
177
|
+
} catch {
|
|
178
|
+
hasComparison = false
|
|
179
|
+
raw = await runReport(accessToken, propertyId, {
|
|
180
|
+
dateRanges: [{ startDate, endDate }],
|
|
181
|
+
dimensions: [{ name: 'date' }],
|
|
182
|
+
metrics: reportMetrics,
|
|
183
|
+
metricAggregations: ['TOTAL'],
|
|
184
|
+
orderBys: [{ dimension: { dimensionName: 'date' } }],
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return transformComparisonReport(raw, hasComparison)
|
|
189
|
+
}),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
// ── GET /realtime ────────────────────────────────────
|
|
193
|
+
router.get(
|
|
194
|
+
'/realtime',
|
|
195
|
+
defineEventHandler(async (event) => {
|
|
196
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
197
|
+
|
|
198
|
+
const res = await fetch(
|
|
199
|
+
`${GA_DATA_API}/properties/${propertyId}:runRealtimeReport`,
|
|
200
|
+
{
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: {
|
|
203
|
+
Authorization: `Bearer ${accessToken}`,
|
|
204
|
+
'Content-Type': 'application/json',
|
|
205
|
+
},
|
|
206
|
+
body: JSON.stringify({
|
|
207
|
+
dimensions: [{ name: 'unifiedScreenName' }],
|
|
208
|
+
metrics: [{ name: 'activeUsers' }],
|
|
209
|
+
orderBys: [{ metric: { metricName: 'activeUsers' }, desc: true }],
|
|
210
|
+
limit: 5,
|
|
211
|
+
}),
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if (!res.ok) {
|
|
216
|
+
const err = await res.json().catch(() => ({}))
|
|
217
|
+
throw createError({ statusCode: res.status, statusMessage: err?.error?.message || 'Realtime API failed' })
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const data = await res.json()
|
|
221
|
+
const totalActive = (data?.rows || []).reduce(
|
|
222
|
+
(sum: number, r: any) => sum + parseInt(r.metricValues?.[0]?.value || '0', 10),
|
|
223
|
+
0,
|
|
224
|
+
)
|
|
225
|
+
const activePages = (data?.rows || []).map((r: any) => ({
|
|
226
|
+
page: r.dimensionValues?.[0]?.value || '(not set)',
|
|
227
|
+
activeUsers: parseInt(r.metricValues?.[0]?.value || '0', 10),
|
|
228
|
+
}))
|
|
229
|
+
|
|
230
|
+
return { activeUsers: totalActive, activePages }
|
|
231
|
+
}),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
// ── GET /top-pages ───────────────────────────────────
|
|
235
|
+
router.get(
|
|
236
|
+
'/top-pages',
|
|
237
|
+
defineEventHandler(async (event) => {
|
|
238
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
239
|
+
const query = getQuery(event)
|
|
240
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
241
|
+
const endDate = (query.endDate as string) || 'today'
|
|
242
|
+
const limit = parseInt((query.limit as string) || '10', 10)
|
|
243
|
+
|
|
244
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
245
|
+
dateRanges: [{ startDate, endDate }],
|
|
246
|
+
dimensions: [{ name: 'pagePath' }],
|
|
247
|
+
metrics: [
|
|
248
|
+
{ name: 'screenPageViews' },
|
|
249
|
+
{ name: 'totalUsers' },
|
|
250
|
+
{ name: 'averageSessionDuration' },
|
|
251
|
+
],
|
|
252
|
+
orderBys: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
|
|
253
|
+
limit: limit * 2, // fetch extra to account for duplicates before aggregation
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
const result = transformDimensionReport(raw, 'pagePath')
|
|
257
|
+
|
|
258
|
+
// Aggregate rows with the same pagePath (GA4 can split the same
|
|
259
|
+
// page across multiple rows due to casing / trailing-slash differences)
|
|
260
|
+
const map = new Map<string, any>()
|
|
261
|
+
for (const row of result.rows) {
|
|
262
|
+
const key = (row.pagePath as string).toLowerCase().replace(/\/+$/, '')
|
|
263
|
+
const existing = map.get(key)
|
|
264
|
+
if (existing) {
|
|
265
|
+
existing.screenPageViews += row.screenPageViews || 0
|
|
266
|
+
existing.totalUsers += row.totalUsers || 0
|
|
267
|
+
// Weighted average for session duration
|
|
268
|
+
const totalViews = existing.screenPageViews
|
|
269
|
+
if (totalViews > 0) {
|
|
270
|
+
existing.averageSessionDuration =
|
|
271
|
+
((existing.averageSessionDuration * (totalViews - (row.screenPageViews || 0)))
|
|
272
|
+
+ (row.averageSessionDuration || 0) * (row.screenPageViews || 0)) / totalViews
|
|
273
|
+
}
|
|
274
|
+
} else {
|
|
275
|
+
map.set(key, { ...row })
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const aggregated = [...map.values()]
|
|
280
|
+
.sort((a, b) => (b.screenPageViews || 0) - (a.screenPageViews || 0))
|
|
281
|
+
.slice(0, limit)
|
|
282
|
+
|
|
283
|
+
return { rows: aggregated, rowCount: result.rowCount }
|
|
284
|
+
}),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
// ── GET /top-sources ─────────────────────────────────
|
|
288
|
+
router.get(
|
|
289
|
+
'/top-sources',
|
|
290
|
+
defineEventHandler(async (event) => {
|
|
291
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
292
|
+
const query = getQuery(event)
|
|
293
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
294
|
+
const endDate = (query.endDate as string) || 'today'
|
|
295
|
+
|
|
296
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
297
|
+
dateRanges: [{ startDate, endDate }],
|
|
298
|
+
dimensions: [{ name: 'sessionSource' }],
|
|
299
|
+
metrics: [
|
|
300
|
+
{ name: 'sessions' },
|
|
301
|
+
{ name: 'totalUsers' },
|
|
302
|
+
],
|
|
303
|
+
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
|
304
|
+
limit: 10,
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
return transformDimensionReport(raw, 'sessionSource')
|
|
308
|
+
}),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
// ── GET /devices ─────────────────────────────────────
|
|
312
|
+
router.get(
|
|
313
|
+
'/devices',
|
|
314
|
+
defineEventHandler(async (event) => {
|
|
315
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
316
|
+
const query = getQuery(event)
|
|
317
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
318
|
+
const endDate = (query.endDate as string) || 'today'
|
|
319
|
+
|
|
320
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
321
|
+
dateRanges: [{ startDate, endDate }],
|
|
322
|
+
dimensions: [{ name: 'deviceCategory' }],
|
|
323
|
+
metrics: [
|
|
324
|
+
{ name: 'sessions' },
|
|
325
|
+
{ name: 'totalUsers' },
|
|
326
|
+
],
|
|
327
|
+
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
return transformDimensionReport(raw, 'deviceCategory')
|
|
331
|
+
}),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
// ── GET /countries ───────────────────────────────────
|
|
335
|
+
router.get(
|
|
336
|
+
'/countries',
|
|
337
|
+
defineEventHandler(async (event) => {
|
|
338
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
339
|
+
const query = getQuery(event)
|
|
340
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
341
|
+
const endDate = (query.endDate as string) || 'today'
|
|
342
|
+
|
|
343
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
344
|
+
dateRanges: [{ startDate, endDate }],
|
|
345
|
+
dimensions: [{ name: 'country' }],
|
|
346
|
+
metrics: [
|
|
347
|
+
{ name: 'sessions' },
|
|
348
|
+
{ name: 'totalUsers' },
|
|
349
|
+
],
|
|
350
|
+
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
|
351
|
+
limit: 10,
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
return transformDimensionReport(raw, 'country')
|
|
355
|
+
}),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
// ═══════════════════════════════════════════════════════
|
|
359
|
+
// ACQUISITION ROUTES
|
|
360
|
+
// ═══════════════════════════════════════════════════════
|
|
361
|
+
|
|
362
|
+
// ── GET /acquisition/channels ────────────────────────
|
|
363
|
+
router.get(
|
|
364
|
+
'/acquisition/channels',
|
|
365
|
+
defineEventHandler(async (event) => {
|
|
366
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
367
|
+
const query = getQuery(event)
|
|
368
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
369
|
+
const endDate = (query.endDate as string) || 'today'
|
|
370
|
+
|
|
371
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
372
|
+
dateRanges: [{ startDate, endDate }],
|
|
373
|
+
dimensions: [{ name: 'sessionDefaultChannelGroup' }],
|
|
374
|
+
metrics: [
|
|
375
|
+
{ name: 'sessions' },
|
|
376
|
+
{ name: 'totalUsers' },
|
|
377
|
+
{ name: 'newUsers' },
|
|
378
|
+
{ name: 'engagementRate' },
|
|
379
|
+
{ name: 'averageSessionDuration' },
|
|
380
|
+
{ name: 'screenPageViewsPerSession' },
|
|
381
|
+
{ name: 'conversions' },
|
|
382
|
+
],
|
|
383
|
+
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
|
384
|
+
limit: 15,
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
return transformDimensionReport(raw, 'sessionDefaultChannelGroup')
|
|
388
|
+
}),
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
// ── GET /acquisition/source-medium ───────────────────
|
|
392
|
+
router.get(
|
|
393
|
+
'/acquisition/source-medium',
|
|
394
|
+
defineEventHandler(async (event) => {
|
|
395
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
396
|
+
const query = getQuery(event)
|
|
397
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
398
|
+
const endDate = (query.endDate as string) || 'today'
|
|
399
|
+
|
|
400
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
401
|
+
dateRanges: [{ startDate, endDate }],
|
|
402
|
+
dimensions: [{ name: 'sessionSourceMedium' }],
|
|
403
|
+
metrics: [
|
|
404
|
+
{ name: 'sessions' },
|
|
405
|
+
{ name: 'totalUsers' },
|
|
406
|
+
{ name: 'newUsers' },
|
|
407
|
+
{ name: 'bounceRate' },
|
|
408
|
+
{ name: 'averageSessionDuration' },
|
|
409
|
+
{ name: 'conversions' },
|
|
410
|
+
],
|
|
411
|
+
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
|
412
|
+
limit: 20,
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
return transformDimensionReport(raw, 'sessionSourceMedium')
|
|
416
|
+
}),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
// ── GET /acquisition/referrals ───────────────────────
|
|
420
|
+
router.get(
|
|
421
|
+
'/acquisition/referrals',
|
|
422
|
+
defineEventHandler(async (event) => {
|
|
423
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
424
|
+
const query = getQuery(event)
|
|
425
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
426
|
+
const endDate = (query.endDate as string) || 'today'
|
|
427
|
+
|
|
428
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
429
|
+
dateRanges: [{ startDate, endDate }],
|
|
430
|
+
dimensions: [{ name: 'sessionSource' }],
|
|
431
|
+
dimensionFilter: {
|
|
432
|
+
filter: {
|
|
433
|
+
fieldName: 'sessionMedium',
|
|
434
|
+
stringFilter: { value: 'referral' },
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
metrics: [
|
|
438
|
+
{ name: 'sessions' },
|
|
439
|
+
{ name: 'totalUsers' },
|
|
440
|
+
{ name: 'engagementRate' },
|
|
441
|
+
{ name: 'averageSessionDuration' },
|
|
442
|
+
],
|
|
443
|
+
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
|
444
|
+
limit: 20,
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
return transformDimensionReport(raw, 'sessionSource')
|
|
448
|
+
}),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
// ── GET /acquisition/campaigns ───────────────────────
|
|
452
|
+
router.get(
|
|
453
|
+
'/acquisition/campaigns',
|
|
454
|
+
defineEventHandler(async (event) => {
|
|
455
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
456
|
+
const query = getQuery(event)
|
|
457
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
458
|
+
const endDate = (query.endDate as string) || 'today'
|
|
459
|
+
|
|
460
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
461
|
+
dateRanges: [{ startDate, endDate }],
|
|
462
|
+
dimensions: [{ name: 'sessionCampaignName' }],
|
|
463
|
+
dimensionFilter: {
|
|
464
|
+
notExpression: {
|
|
465
|
+
filter: {
|
|
466
|
+
fieldName: 'sessionCampaignName',
|
|
467
|
+
stringFilter: { value: '(not set)' },
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
},
|
|
471
|
+
metrics: [
|
|
472
|
+
{ name: 'sessions' },
|
|
473
|
+
{ name: 'totalUsers' },
|
|
474
|
+
{ name: 'conversions' },
|
|
475
|
+
{ name: 'engagementRate' },
|
|
476
|
+
],
|
|
477
|
+
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
|
478
|
+
limit: 20,
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
return transformDimensionReport(raw, 'sessionCampaignName')
|
|
482
|
+
}),
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
// ═══════════════════════════════════════════════════════
|
|
486
|
+
// CONTENT / SEO ROUTES
|
|
487
|
+
// ═══════════════════════════════════════════════════════
|
|
488
|
+
|
|
489
|
+
// ── GET /content/all-pages ───────────────────────────
|
|
490
|
+
router.get(
|
|
491
|
+
'/content/all-pages',
|
|
492
|
+
defineEventHandler(async (event) => {
|
|
493
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
494
|
+
const query = getQuery(event)
|
|
495
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
496
|
+
const endDate = (query.endDate as string) || 'today'
|
|
497
|
+
const limit = parseInt((query.limit as string) || '50', 10)
|
|
498
|
+
|
|
499
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
500
|
+
dateRanges: [{ startDate, endDate }],
|
|
501
|
+
dimensions: [{ name: 'pagePath' }],
|
|
502
|
+
metrics: [
|
|
503
|
+
{ name: 'screenPageViews' },
|
|
504
|
+
{ name: 'totalUsers' },
|
|
505
|
+
{ name: 'averageSessionDuration' },
|
|
506
|
+
{ name: 'bounceRate' },
|
|
507
|
+
{ name: 'engagementRate' },
|
|
508
|
+
{ name: 'sessions' },
|
|
509
|
+
{ name: 'userEngagementDuration' },
|
|
510
|
+
],
|
|
511
|
+
orderBys: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
|
|
512
|
+
limit,
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
return transformDimensionReport(raw, 'pagePath')
|
|
516
|
+
}),
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
// ── GET /content/landing-pages ───────────────────────
|
|
520
|
+
router.get(
|
|
521
|
+
'/content/landing-pages',
|
|
522
|
+
defineEventHandler(async (event) => {
|
|
523
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
524
|
+
const query = getQuery(event)
|
|
525
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
526
|
+
const endDate = (query.endDate as string) || 'today'
|
|
527
|
+
|
|
528
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
529
|
+
dateRanges: [{ startDate, endDate }],
|
|
530
|
+
dimensions: [{ name: 'landingPagePlusQueryString' }],
|
|
531
|
+
metrics: [
|
|
532
|
+
{ name: 'sessions' },
|
|
533
|
+
{ name: 'totalUsers' },
|
|
534
|
+
{ name: 'bounceRate' },
|
|
535
|
+
{ name: 'averageSessionDuration' },
|
|
536
|
+
{ name: 'screenPageViews' },
|
|
537
|
+
{ name: 'engagementRate' },
|
|
538
|
+
],
|
|
539
|
+
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
|
540
|
+
limit: 30,
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
return transformDimensionReport(raw, 'landingPagePlusQueryString')
|
|
544
|
+
}),
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
// ── GET /content/exit-pages ──────────────────────────
|
|
548
|
+
router.get(
|
|
549
|
+
'/content/exit-pages',
|
|
550
|
+
defineEventHandler(async (event) => {
|
|
551
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
552
|
+
const query = getQuery(event)
|
|
553
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
554
|
+
const endDate = (query.endDate as string) || 'today'
|
|
555
|
+
|
|
556
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
557
|
+
dateRanges: [{ startDate, endDate }],
|
|
558
|
+
dimensions: [{ name: 'pagePath' }],
|
|
559
|
+
metrics: [
|
|
560
|
+
{ name: 'sessions' },
|
|
561
|
+
{ name: 'screenPageViews' },
|
|
562
|
+
{ name: 'totalUsers' },
|
|
563
|
+
{ name: 'bounceRate' },
|
|
564
|
+
],
|
|
565
|
+
orderBys: [{ metric: { metricName: 'screenPageViews' }, desc: true }],
|
|
566
|
+
limit: 20,
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
// Compute approximate exit rate from bounce rate
|
|
570
|
+
const result = transformDimensionReport(raw, 'pagePath')
|
|
571
|
+
result.rows = result.rows.map((row: any) => ({
|
|
572
|
+
...row,
|
|
573
|
+
exitRate: row.bounceRate || 0,
|
|
574
|
+
}))
|
|
575
|
+
return result
|
|
576
|
+
}),
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
// ── GET /content/search-terms ──────────────────────────
|
|
580
|
+
// Step 1: Try Google Search Console (real organic keywords).
|
|
581
|
+
// Step 2: Try Google Ads search queries from GA4.
|
|
582
|
+
// Step 3: Fall back to top organic-search landing pages.
|
|
583
|
+
router.get(
|
|
584
|
+
'/content/search-terms',
|
|
585
|
+
defineEventHandler(async (event) => {
|
|
586
|
+
const { accessToken, propertyId, siteUrl } = await getGAContext(event)
|
|
587
|
+
const query = getQuery(event)
|
|
588
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
589
|
+
const endDate = (query.endDate as string) || 'today'
|
|
590
|
+
|
|
591
|
+
// 1) Try Google Search Console if siteUrl is configured
|
|
592
|
+
if (siteUrl) {
|
|
593
|
+
try {
|
|
594
|
+
const gscUrl = `${SEARCH_CONSOLE_API}/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`
|
|
595
|
+
console.log('[GA Module] Attempting Search Console query for site:', siteUrl)
|
|
596
|
+
const gscRes = await fetch(
|
|
597
|
+
gscUrl,
|
|
598
|
+
{
|
|
599
|
+
method: 'POST',
|
|
600
|
+
headers: {
|
|
601
|
+
Authorization: `Bearer ${accessToken}`,
|
|
602
|
+
'Content-Type': 'application/json',
|
|
603
|
+
},
|
|
604
|
+
body: JSON.stringify({
|
|
605
|
+
startDate: resolveDate(startDate),
|
|
606
|
+
endDate: resolveDate(endDate),
|
|
607
|
+
dimensions: ['query'],
|
|
608
|
+
rowLimit: 30,
|
|
609
|
+
}),
|
|
610
|
+
},
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
if (gscRes.ok) {
|
|
614
|
+
const gscData = await gscRes.json()
|
|
615
|
+
const rows = (gscData.rows || []).map((row: any) => ({
|
|
616
|
+
query: row.keys[0],
|
|
617
|
+
clicks: row.clicks,
|
|
618
|
+
impressions: row.impressions,
|
|
619
|
+
ctr: row.ctr,
|
|
620
|
+
position: row.position,
|
|
621
|
+
}))
|
|
622
|
+
|
|
623
|
+
if (rows.length > 0) {
|
|
624
|
+
console.log(`[GA Module] Search Console returned ${rows.length} keyword rows`)
|
|
625
|
+
return {
|
|
626
|
+
rows,
|
|
627
|
+
rowCount: rows.length,
|
|
628
|
+
source: 'search_console',
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
console.log('[GA Module] Search Console returned 0 rows, falling through')
|
|
632
|
+
} else {
|
|
633
|
+
const errBody = await gscRes.text().catch(() => '')
|
|
634
|
+
console.error(`[GA Module] Search Console API error (${gscRes.status}):`, errBody)
|
|
635
|
+
}
|
|
636
|
+
} catch (gscErr: any) {
|
|
637
|
+
console.error('[GA Module] Search Console request failed:', gscErr?.message || gscErr)
|
|
638
|
+
}
|
|
639
|
+
} else {
|
|
640
|
+
console.log('[GA Module] No siteUrl configured, skipping Search Console')
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// 2) Try sessionGoogleAdsQuery (real search query text from Google Ads)
|
|
644
|
+
try {
|
|
645
|
+
const adsRaw = await runReport(accessToken, propertyId, {
|
|
646
|
+
dateRanges: [{ startDate, endDate }],
|
|
647
|
+
dimensions: [{ name: 'sessionGoogleAdsQuery' }],
|
|
648
|
+
metrics: [
|
|
649
|
+
{ name: 'sessions' },
|
|
650
|
+
{ name: 'totalUsers' },
|
|
651
|
+
{ name: 'engagementRate' },
|
|
652
|
+
],
|
|
653
|
+
dimensionFilter: {
|
|
654
|
+
andGroup: {
|
|
655
|
+
expressions: [
|
|
656
|
+
{
|
|
657
|
+
notExpression: {
|
|
658
|
+
filter: {
|
|
659
|
+
fieldName: 'sessionGoogleAdsQuery',
|
|
660
|
+
stringFilter: { value: '(not set)' },
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
{
|
|
665
|
+
notExpression: {
|
|
666
|
+
filter: {
|
|
667
|
+
fieldName: 'sessionGoogleAdsQuery',
|
|
668
|
+
stringFilter: { value: '(not provided)' },
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
},
|
|
672
|
+
],
|
|
673
|
+
},
|
|
674
|
+
},
|
|
675
|
+
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
|
676
|
+
limit: 30,
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
const adsResult = transformDimensionReport(adsRaw, 'sessionGoogleAdsQuery')
|
|
680
|
+
if (adsResult.rows.length > 0) {
|
|
681
|
+
return { ...adsResult, source: 'google_ads' }
|
|
682
|
+
}
|
|
683
|
+
} catch {
|
|
684
|
+
// Dimension may not be available — fall through to organic landing pages
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// 3) Fallback: top landing pages from organic search traffic
|
|
688
|
+
const organicRaw = await runReport(accessToken, propertyId, {
|
|
689
|
+
dateRanges: [{ startDate, endDate }],
|
|
690
|
+
dimensions: [{ name: 'landingPagePlusQueryString' }],
|
|
691
|
+
metrics: [
|
|
692
|
+
{ name: 'sessions' },
|
|
693
|
+
{ name: 'totalUsers' },
|
|
694
|
+
{ name: 'engagementRate' },
|
|
695
|
+
],
|
|
696
|
+
dimensionFilter: {
|
|
697
|
+
andGroup: {
|
|
698
|
+
expressions: [
|
|
699
|
+
{
|
|
700
|
+
filter: {
|
|
701
|
+
fieldName: 'sessionMedium',
|
|
702
|
+
stringFilter: {
|
|
703
|
+
matchType: 'EXACT',
|
|
704
|
+
value: 'organic',
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
},
|
|
708
|
+
{
|
|
709
|
+
notExpression: {
|
|
710
|
+
filter: {
|
|
711
|
+
fieldName: 'landingPagePlusQueryString',
|
|
712
|
+
stringFilter: { value: '(not set)' },
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
orderBys: [{ metric: { metricName: 'sessions' }, desc: true }],
|
|
720
|
+
limit: 30,
|
|
721
|
+
})
|
|
722
|
+
|
|
723
|
+
const organicResult = transformDimensionReport(organicRaw, 'landingPagePlusQueryString')
|
|
724
|
+
return { ...organicResult, source: 'organic_landing_pages' }
|
|
725
|
+
}),
|
|
726
|
+
)
|
|
727
|
+
|
|
728
|
+
// ═══════════════════════════════════════════════════════
|
|
729
|
+
// AUDIENCE ROUTES
|
|
730
|
+
// ═══════════════════════════════════════════════════════
|
|
731
|
+
|
|
732
|
+
// ── GET /audience/overview ───────────────────────────
|
|
733
|
+
router.get(
|
|
734
|
+
'/audience/overview',
|
|
735
|
+
defineEventHandler(async (event) => {
|
|
736
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
737
|
+
const query = getQuery(event)
|
|
738
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
739
|
+
const endDate = (query.endDate as string) || 'today'
|
|
740
|
+
|
|
741
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
742
|
+
dateRanges: [{ startDate, endDate }],
|
|
743
|
+
dimensions: [{ name: 'newVsReturning' }],
|
|
744
|
+
metrics: [
|
|
745
|
+
{ name: 'totalUsers' },
|
|
746
|
+
{ name: 'sessions' },
|
|
747
|
+
{ name: 'engagementRate' },
|
|
748
|
+
{ name: 'averageSessionDuration' },
|
|
749
|
+
{ name: 'screenPageViewsPerSession' },
|
|
750
|
+
],
|
|
751
|
+
metricAggregations: ['TOTAL'],
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
return transformDimensionReport(raw, 'newVsReturning')
|
|
755
|
+
}),
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
// ── GET /audience/technology ─────────────────────────
|
|
759
|
+
router.get(
|
|
760
|
+
'/audience/technology',
|
|
761
|
+
defineEventHandler(async (event) => {
|
|
762
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
763
|
+
const query = getQuery(event)
|
|
764
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
765
|
+
const endDate = (query.endDate as string) || 'today'
|
|
766
|
+
const dimension = (query.dimension as string) || 'browser'
|
|
767
|
+
|
|
768
|
+
// Allow switching between browser, operatingSystem, screenResolution
|
|
769
|
+
const allowedDimensions = ['browser', 'operatingSystem', 'screenResolution']
|
|
770
|
+
const dim = allowedDimensions.includes(dimension) ? dimension : 'browser'
|
|
771
|
+
|
|
772
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
773
|
+
dateRanges: [{ startDate, endDate }],
|
|
774
|
+
dimensions: [{ name: dim }],
|
|
775
|
+
metrics: [
|
|
776
|
+
{ name: 'totalUsers' },
|
|
777
|
+
{ name: 'sessions' },
|
|
778
|
+
{ name: 'engagementRate' },
|
|
779
|
+
],
|
|
780
|
+
orderBys: [{ metric: { metricName: 'totalUsers' }, desc: true }],
|
|
781
|
+
limit: 10,
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
return transformDimensionReport(raw, dim)
|
|
785
|
+
}),
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
// ── GET /audience/languages ──────────────────────────
|
|
789
|
+
router.get(
|
|
790
|
+
'/audience/languages',
|
|
791
|
+
defineEventHandler(async (event) => {
|
|
792
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
793
|
+
const query = getQuery(event)
|
|
794
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
795
|
+
const endDate = (query.endDate as string) || 'today'
|
|
796
|
+
|
|
797
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
798
|
+
dateRanges: [{ startDate, endDate }],
|
|
799
|
+
dimensions: [{ name: 'language' }],
|
|
800
|
+
metrics: [
|
|
801
|
+
{ name: 'totalUsers' },
|
|
802
|
+
{ name: 'sessions' },
|
|
803
|
+
],
|
|
804
|
+
orderBys: [{ metric: { metricName: 'totalUsers' }, desc: true }],
|
|
805
|
+
limit: 15,
|
|
806
|
+
})
|
|
807
|
+
|
|
808
|
+
return transformDimensionReport(raw, 'language')
|
|
809
|
+
}),
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
// ── GET /audience/hours ──────────────────────────────
|
|
813
|
+
// Peak traffic hours heatmap data
|
|
814
|
+
router.get(
|
|
815
|
+
'/audience/hours',
|
|
816
|
+
defineEventHandler(async (event) => {
|
|
817
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
818
|
+
const query = getQuery(event)
|
|
819
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
820
|
+
const endDate = (query.endDate as string) || 'today'
|
|
821
|
+
|
|
822
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
823
|
+
dateRanges: [{ startDate, endDate }],
|
|
824
|
+
dimensions: [{ name: 'dayOfWeekName' }, { name: 'hour' }],
|
|
825
|
+
metrics: [{ name: 'sessions' }],
|
|
826
|
+
orderBys: [
|
|
827
|
+
{ dimension: { dimensionName: 'dayOfWeekName' } },
|
|
828
|
+
{ dimension: { dimensionName: 'hour' } },
|
|
829
|
+
],
|
|
830
|
+
limit: 168, // 7 days × 24 hours
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
return transformMultiDimensionReport(raw, ['dayOfWeekName', 'hour'])
|
|
834
|
+
}),
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
// ── GET /audience/cities ─────────────────────────────
|
|
838
|
+
router.get(
|
|
839
|
+
'/audience/cities',
|
|
840
|
+
defineEventHandler(async (event) => {
|
|
841
|
+
const { accessToken, propertyId } = await getGAContext(event)
|
|
842
|
+
const query = getQuery(event)
|
|
843
|
+
const startDate = (query.startDate as string) || '30daysAgo'
|
|
844
|
+
const endDate = (query.endDate as string) || 'today'
|
|
845
|
+
|
|
846
|
+
const raw = await runReport(accessToken, propertyId, {
|
|
847
|
+
dateRanges: [{ startDate, endDate }],
|
|
848
|
+
dimensions: [{ name: 'city' }, { name: 'country' }],
|
|
849
|
+
metrics: [
|
|
850
|
+
{ name: 'totalUsers' },
|
|
851
|
+
{ name: 'sessions' },
|
|
852
|
+
],
|
|
853
|
+
dimensionFilter: {
|
|
854
|
+
notExpression: {
|
|
855
|
+
filter: {
|
|
856
|
+
fieldName: 'city',
|
|
857
|
+
stringFilter: { value: '(not set)' },
|
|
858
|
+
},
|
|
859
|
+
},
|
|
860
|
+
},
|
|
861
|
+
orderBys: [{ metric: { metricName: 'totalUsers' }, desc: true }],
|
|
862
|
+
limit: 20,
|
|
863
|
+
})
|
|
864
|
+
|
|
865
|
+
return transformMultiDimensionReport(raw, ['city', 'country'])
|
|
866
|
+
}),
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
// ═══════════════════════════════════════════════════════
|
|
870
|
+
// SEO / SEARCH CONSOLE ROUTES
|
|
871
|
+
// ═══════════════════════════════════════════════════════
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Helper: resolve relative GA dates like "30daysAgo" to YYYY-MM-DD for Search Console.
|
|
875
|
+
*/
|
|
876
|
+
function resolveDate(d: string): string {
|
|
877
|
+
const relative = d.match(/^(\d+)daysAgo$/)
|
|
878
|
+
if (relative) {
|
|
879
|
+
const dt = new Date()
|
|
880
|
+
dt.setDate(dt.getDate() - parseInt(relative[1], 10))
|
|
881
|
+
return dt.toISOString().slice(0, 10)
|
|
882
|
+
}
|
|
883
|
+
if (d === 'today') return new Date().toISOString().slice(0, 10)
|
|
884
|
+
if (d === 'yesterday') {
|
|
885
|
+
const dt = new Date()
|
|
886
|
+
dt.setDate(dt.getDate() - 1)
|
|
887
|
+
return dt.toISOString().slice(0, 10)
|
|
888
|
+
}
|
|
889
|
+
return d
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Call the Search Console searchAnalytics/query endpoint.
|
|
894
|
+
*/
|
|
895
|
+
async function runSearchConsoleQuery(
|
|
896
|
+
accessToken: string,
|
|
897
|
+
siteUrl: string,
|
|
898
|
+
body: Record<string, any>,
|
|
899
|
+
) {
|
|
900
|
+
const url = `${SEARCH_CONSOLE_API}/sites/${encodeURIComponent(siteUrl)}/searchAnalytics/query`
|
|
901
|
+
const res = await fetch(url, {
|
|
902
|
+
method: 'POST',
|
|
903
|
+
headers: {
|
|
904
|
+
Authorization: `Bearer ${accessToken}`,
|
|
905
|
+
'Content-Type': 'application/json',
|
|
906
|
+
},
|
|
907
|
+
body: JSON.stringify(body),
|
|
908
|
+
})
|
|
909
|
+
if (!res.ok) {
|
|
910
|
+
const err = await res.json().catch(() => ({}))
|
|
911
|
+
throw createError({
|
|
912
|
+
statusCode: res.status,
|
|
913
|
+
statusMessage: err?.error?.message || 'Search Console API request failed',
|
|
914
|
+
})
|
|
915
|
+
}
|
|
916
|
+
return res.json()
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// ── GET /seo/keywords ────────────────────────────────
|
|
920
|
+
// Top search queries with clicks, impressions, CTR, position
|
|
921
|
+
router.get(
|
|
922
|
+
'/seo/keywords',
|
|
923
|
+
defineEventHandler(async (event) => {
|
|
924
|
+
const { accessToken, siteUrl } = await getGAContext(event)
|
|
925
|
+
if (!siteUrl) {
|
|
926
|
+
throw createError({ statusCode: 400, statusMessage: 'Search Console Site URL is not configured. Add it in module settings.' })
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const query = getQuery(event)
|
|
930
|
+
const startDate = resolveDate((query.startDate as string) || '30daysAgo')
|
|
931
|
+
const endDate = resolveDate((query.endDate as string) || 'today')
|
|
932
|
+
const limit = Math.min(parseInt(query.limit as string) || 50, 100)
|
|
933
|
+
|
|
934
|
+
const data = await runSearchConsoleQuery(accessToken, siteUrl, {
|
|
935
|
+
startDate,
|
|
936
|
+
endDate,
|
|
937
|
+
dimensions: ['query'],
|
|
938
|
+
rowLimit: limit,
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
return {
|
|
942
|
+
rows: (data.rows || []).map((r: any) => ({
|
|
943
|
+
query: r.keys[0],
|
|
944
|
+
clicks: r.clicks,
|
|
945
|
+
impressions: r.impressions,
|
|
946
|
+
ctr: r.ctr,
|
|
947
|
+
position: r.position,
|
|
948
|
+
})),
|
|
949
|
+
rowCount: data.rows?.length || 0,
|
|
950
|
+
}
|
|
951
|
+
}),
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
// ── GET /seo/pages ───────────────────────────────────
|
|
955
|
+
// Top pages by Search Console performance
|
|
956
|
+
router.get(
|
|
957
|
+
'/seo/pages',
|
|
958
|
+
defineEventHandler(async (event) => {
|
|
959
|
+
const { accessToken, siteUrl } = await getGAContext(event)
|
|
960
|
+
if (!siteUrl) {
|
|
961
|
+
throw createError({ statusCode: 400, statusMessage: 'Search Console Site URL is not configured.' })
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
const query = getQuery(event)
|
|
965
|
+
const startDate = resolveDate((query.startDate as string) || '30daysAgo')
|
|
966
|
+
const endDate = resolveDate((query.endDate as string) || 'today')
|
|
967
|
+
const limit = Math.min(parseInt(query.limit as string) || 50, 100)
|
|
968
|
+
|
|
969
|
+
const data = await runSearchConsoleQuery(accessToken, siteUrl, {
|
|
970
|
+
startDate,
|
|
971
|
+
endDate,
|
|
972
|
+
dimensions: ['page'],
|
|
973
|
+
rowLimit: limit,
|
|
974
|
+
})
|
|
975
|
+
|
|
976
|
+
return {
|
|
977
|
+
rows: (data.rows || []).map((r: any) => ({
|
|
978
|
+
page: r.keys[0],
|
|
979
|
+
clicks: r.clicks,
|
|
980
|
+
impressions: r.impressions,
|
|
981
|
+
ctr: r.ctr,
|
|
982
|
+
position: r.position,
|
|
983
|
+
})),
|
|
984
|
+
rowCount: data.rows?.length || 0,
|
|
985
|
+
}
|
|
986
|
+
}),
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
// ── GET /seo/trends ──────────────────────────────────
|
|
990
|
+
// Daily clicks/impressions/CTR/position trend data
|
|
991
|
+
router.get(
|
|
992
|
+
'/seo/trends',
|
|
993
|
+
defineEventHandler(async (event) => {
|
|
994
|
+
const { accessToken, siteUrl } = await getGAContext(event)
|
|
995
|
+
if (!siteUrl) {
|
|
996
|
+
throw createError({ statusCode: 400, statusMessage: 'Search Console Site URL is not configured.' })
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const query = getQuery(event)
|
|
1000
|
+
const startDate = resolveDate((query.startDate as string) || '30daysAgo')
|
|
1001
|
+
const endDate = resolveDate((query.endDate as string) || 'today')
|
|
1002
|
+
|
|
1003
|
+
const data = await runSearchConsoleQuery(accessToken, siteUrl, {
|
|
1004
|
+
startDate,
|
|
1005
|
+
endDate,
|
|
1006
|
+
dimensions: ['date'],
|
|
1007
|
+
rowLimit: 500,
|
|
1008
|
+
})
|
|
1009
|
+
|
|
1010
|
+
const rows = (data.rows || [])
|
|
1011
|
+
.map((r: any) => ({
|
|
1012
|
+
date: r.keys[0],
|
|
1013
|
+
clicks: r.clicks,
|
|
1014
|
+
impressions: r.impressions,
|
|
1015
|
+
ctr: r.ctr,
|
|
1016
|
+
position: r.position,
|
|
1017
|
+
}))
|
|
1018
|
+
.sort((a: any, b: any) => a.date.localeCompare(b.date))
|
|
1019
|
+
|
|
1020
|
+
// Compute totals
|
|
1021
|
+
const totals = rows.reduce(
|
|
1022
|
+
(acc: any, r: any) => ({
|
|
1023
|
+
clicks: acc.clicks + r.clicks,
|
|
1024
|
+
impressions: acc.impressions + r.impressions,
|
|
1025
|
+
}),
|
|
1026
|
+
{ clicks: 0, impressions: 0 },
|
|
1027
|
+
)
|
|
1028
|
+
totals.ctr = totals.impressions > 0 ? totals.clicks / totals.impressions : 0
|
|
1029
|
+
totals.avgPosition =
|
|
1030
|
+
rows.length > 0 ? rows.reduce((s: number, r: any) => s + r.position, 0) / rows.length : 0
|
|
1031
|
+
|
|
1032
|
+
return { rows, totals, rowCount: rows.length }
|
|
1033
|
+
}),
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
// ── GET /seo/query-pages ─────────────────────────────
|
|
1037
|
+
// Top queries grouped by page (for keyword-to-page mapping)
|
|
1038
|
+
router.get(
|
|
1039
|
+
'/seo/query-pages',
|
|
1040
|
+
defineEventHandler(async (event) => {
|
|
1041
|
+
const { accessToken, siteUrl } = await getGAContext(event)
|
|
1042
|
+
if (!siteUrl) {
|
|
1043
|
+
throw createError({ statusCode: 400, statusMessage: 'Search Console Site URL is not configured.' })
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const query = getQuery(event)
|
|
1047
|
+
const startDate = resolveDate((query.startDate as string) || '30daysAgo')
|
|
1048
|
+
const endDate = resolveDate((query.endDate as string) || 'today')
|
|
1049
|
+
|
|
1050
|
+
const data = await runSearchConsoleQuery(accessToken, siteUrl, {
|
|
1051
|
+
startDate,
|
|
1052
|
+
endDate,
|
|
1053
|
+
dimensions: ['query', 'page'],
|
|
1054
|
+
rowLimit: 100,
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
return {
|
|
1058
|
+
rows: (data.rows || []).map((r: any) => ({
|
|
1059
|
+
query: r.keys[0],
|
|
1060
|
+
page: r.keys[1],
|
|
1061
|
+
clicks: r.clicks,
|
|
1062
|
+
impressions: r.impressions,
|
|
1063
|
+
ctr: r.ctr,
|
|
1064
|
+
position: r.position,
|
|
1065
|
+
})),
|
|
1066
|
+
rowCount: data.rows?.length || 0,
|
|
1067
|
+
}
|
|
1068
|
+
}),
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
return router.handler
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// ═══════════════════════════════════════════════════════
|
|
1075
|
+
// RESPONSE TRANSFORMERS
|
|
1076
|
+
// ═══════════════════════════════════════════════════════
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Transform a GA4 date-dimensioned report with two date ranges into a comparison report.
|
|
1080
|
+
* Returns current period rows, current totals, and previous totals for % change.
|
|
1081
|
+
*/
|
|
1082
|
+
function transformComparisonReport(raw: any, hasComparison = true) {
|
|
1083
|
+
const metricHeaders = (raw.metricHeaders || []).map((h: any) => h.name)
|
|
1084
|
+
const dimHeaders = (raw.dimensionHeaders || []).map((h: any) => h.name)
|
|
1085
|
+
const dateIdx = dimHeaders.indexOf('date')
|
|
1086
|
+
|
|
1087
|
+
const allRows: any[] = []
|
|
1088
|
+
|
|
1089
|
+
;(raw.rows || []).forEach((row: any) => {
|
|
1090
|
+
const dateVal = dateIdx >= 0 ? row.dimensionValues[dateIdx]?.value : row.dimensionValues[0]?.value
|
|
1091
|
+
if (!dateVal || dateVal.length < 8) return
|
|
1092
|
+
|
|
1093
|
+
const date = `${dateVal.slice(0, 4)}-${dateVal.slice(4, 6)}-${dateVal.slice(6, 8)}`
|
|
1094
|
+
const entry: Record<string, any> = { date }
|
|
1095
|
+
metricHeaders.forEach((metric: string, i: number) => {
|
|
1096
|
+
entry[metric] = parseFloat(row.metricValues[i]?.value || '0')
|
|
1097
|
+
})
|
|
1098
|
+
allRows.push(entry)
|
|
1099
|
+
})
|
|
1100
|
+
|
|
1101
|
+
// Extract totals
|
|
1102
|
+
const currentTotals: Record<string, number> = {}
|
|
1103
|
+
const previousTotals: Record<string, number> = {}
|
|
1104
|
+
|
|
1105
|
+
if (raw.totals && raw.totals.length >= 2) {
|
|
1106
|
+
metricHeaders.forEach((metric: string, i: number) => {
|
|
1107
|
+
currentTotals[metric] = parseFloat(raw.totals[0]?.metricValues?.[i]?.value || '0')
|
|
1108
|
+
previousTotals[metric] = parseFloat(raw.totals[1]?.metricValues?.[i]?.value || '0')
|
|
1109
|
+
})
|
|
1110
|
+
} else if (raw.totals && raw.totals.length === 1) {
|
|
1111
|
+
metricHeaders.forEach((metric: string, i: number) => {
|
|
1112
|
+
currentTotals[metric] = parseFloat(raw.totals[0]?.metricValues?.[i]?.value || '0')
|
|
1113
|
+
})
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// Compute % change
|
|
1117
|
+
const changes: Record<string, number | null> = {}
|
|
1118
|
+
metricHeaders.forEach((metric: string) => {
|
|
1119
|
+
const curr = currentTotals[metric] || 0
|
|
1120
|
+
const prev = previousTotals[metric]
|
|
1121
|
+
if (prev !== undefined && prev !== 0) {
|
|
1122
|
+
changes[metric] = ((curr - prev) / prev) * 100
|
|
1123
|
+
} else {
|
|
1124
|
+
changes[metric] = null
|
|
1125
|
+
}
|
|
1126
|
+
})
|
|
1127
|
+
|
|
1128
|
+
// Deduplicate by date, sort chronologically
|
|
1129
|
+
const byDate = new Map<string, any>()
|
|
1130
|
+
for (const row of allRows) {
|
|
1131
|
+
if (!byDate.has(row.date)) {
|
|
1132
|
+
byDate.set(row.date, row)
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
const rows = Array.from(byDate.values()).sort((a, b) => a.date.localeCompare(b.date))
|
|
1136
|
+
|
|
1137
|
+
// If we had a comparison (dual date ranges), only keep the more recent half (current period)
|
|
1138
|
+
const chartRows = hasComparison && raw.totals && raw.totals.length >= 2
|
|
1139
|
+
? rows.slice(-Math.ceil(rows.length / 2))
|
|
1140
|
+
: rows
|
|
1141
|
+
|
|
1142
|
+
return {
|
|
1143
|
+
rows: chartRows,
|
|
1144
|
+
totals: currentTotals,
|
|
1145
|
+
previousTotals,
|
|
1146
|
+
changes,
|
|
1147
|
+
rowCount: chartRows.length,
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Transform a GA4 single-dimension report into a ranked list.
|
|
1153
|
+
*/
|
|
1154
|
+
function transformDimensionReport(raw: any, dimensionKey: string) {
|
|
1155
|
+
const metricHeaders = (raw.metricHeaders || []).map((h: any) => h.name)
|
|
1156
|
+
|
|
1157
|
+
const rows = (raw.rows || []).map((row: any) => {
|
|
1158
|
+
const entry: Record<string, any> = {
|
|
1159
|
+
[dimensionKey]: row.dimensionValues[0].value,
|
|
1160
|
+
}
|
|
1161
|
+
metricHeaders.forEach((metric: string, i: number) => {
|
|
1162
|
+
entry[metric] = parseFloat(row.metricValues[i].value)
|
|
1163
|
+
})
|
|
1164
|
+
return entry
|
|
1165
|
+
})
|
|
1166
|
+
|
|
1167
|
+
return { rows, rowCount: raw.rowCount || rows.length }
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Transform a GA4 multi-dimension report (e.g. pagePath + pageTitle, or dayOfWeek + hour).
|
|
1172
|
+
*/
|
|
1173
|
+
function transformMultiDimensionReport(raw: any, dimensionKeys: string[]) {
|
|
1174
|
+
const metricHeaders = (raw.metricHeaders || []).map((h: any) => h.name)
|
|
1175
|
+
|
|
1176
|
+
const rows = (raw.rows || []).map((row: any) => {
|
|
1177
|
+
const entry: Record<string, any> = {}
|
|
1178
|
+
dimensionKeys.forEach((key, di) => {
|
|
1179
|
+
entry[key] = row.dimensionValues[di]?.value || ''
|
|
1180
|
+
})
|
|
1181
|
+
metricHeaders.forEach((metric: string, i: number) => {
|
|
1182
|
+
entry[metric] = parseFloat(row.metricValues[i].value)
|
|
1183
|
+
})
|
|
1184
|
+
return entry
|
|
1185
|
+
})
|
|
1186
|
+
|
|
1187
|
+
return { rows, rowCount: raw.rowCount || rows.length }
|
|
1188
|
+
}
|