mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
B79 — chassis do bottom drawer de ações rápidas + busca global (mobile)
Monta CampaignQuickActionsDrawer não-modal no layout (app) para staff fora do Início e de /acoes, com snap collapsed/expanded, padding de peek no scroll, registry vazio para catálogos B80+, e busca global reutilizada via CampaignStaffGlobalSearch. Closes #15 Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
parent
b2b11c3dfe
commit
a2eabaccba
15 changed files with 461 additions and 47 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Chassis do bottom drawer de ações rápidas + busca (mobile)
|
||||
|
||||
Status: in-progress (Issue #15) — **fatiado 2026-07-31:** busca embutida → **B91**
|
||||
Status: entregue (B79 chassis — busca → B91)
|
||||
Atualizado em: 2026-07-31
|
||||
Issue: #15 (B79)
|
||||
Priority: P1
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ import {
|
|||
BiometricEnrollmentToast,
|
||||
type BiometricEnrollmentOffer,
|
||||
} from '@/components/campaign/shell/BiometricEnrollmentToast'
|
||||
import { CampaignAppScrollChrome } from '@/components/campaign/shell/CampaignAppScrollChrome'
|
||||
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
|
||||
import { CampaignNotificationBellSlot } from '@/components/campaign/shell/CampaignNotificationBellSlot'
|
||||
import { CampaignQuickActionContextProvider } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import { CampaignSidebar } from '@/components/campaign/shell/CampaignSidebar'
|
||||
import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/CampaignSidebarViewportDefault'
|
||||
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
|
||||
|
|
@ -73,24 +75,23 @@ export default async function CampaignAppLayout({ children }: { children: React.
|
|||
<CampaignSidebar user={campaignUserShellView(user)} />
|
||||
<SidebarInset className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
|
||||
<CampaignWizardChromeProvider>
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignMobileTopBar notificationBell={<CampaignNotificationBellSlot user={user} />} />
|
||||
<header className="hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden">
|
||||
<SidebarTrigger />
|
||||
<div className="ml-auto">
|
||||
<CampaignNotificationBellSlot user={user} />
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
data-slot="campaign-content-scroll"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 md:p-6 print:h-auto print:overflow-visible print:p-0"
|
||||
>
|
||||
<TooltipProvider delayDuration={300}>{children}</TooltipProvider>
|
||||
</div>
|
||||
<Toaster position="top-center" />
|
||||
<InstallPwaToast />
|
||||
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}
|
||||
</CampaignListPendingBoundary>
|
||||
<CampaignQuickActionContextProvider>
|
||||
<CampaignListPendingBoundary>
|
||||
<CampaignMobileTopBar notificationBell={<CampaignNotificationBellSlot user={user} />} />
|
||||
<header className="hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden">
|
||||
<SidebarTrigger />
|
||||
<div className="ml-auto">
|
||||
<CampaignNotificationBellSlot user={user} />
|
||||
</div>
|
||||
</header>
|
||||
<CampaignAppScrollChrome role={user.role}>
|
||||
<TooltipProvider delayDuration={300}>{children}</TooltipProvider>
|
||||
</CampaignAppScrollChrome>
|
||||
<Toaster position="top-center" />
|
||||
<InstallPwaToast />
|
||||
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}
|
||||
</CampaignListPendingBoundary>
|
||||
</CampaignQuickActionContextProvider>
|
||||
</CampaignWizardChromeProvider>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
|
|
|||
|
|
@ -6,14 +6,6 @@ import { CampaignHomeActions } from '@/components/campaign/dashboard/CampaignHom
|
|||
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
|
||||
import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/CampaignHomeStaffChrome'
|
||||
import { CampaignHomeSummary } from '@/components/campaign/dashboard/CampaignHomeSummary'
|
||||
import { HomeSearchActivityGroup } from '@/components/campaign/dashboard/HomeSearchActivityGroup'
|
||||
import { HomeSearchAdvisorGroup } from '@/components/campaign/dashboard/HomeSearchAdvisorGroup'
|
||||
import { HomeSearchDemandGroup } from '@/components/campaign/dashboard/HomeSearchDemandGroup'
|
||||
import { HomeSearchLeadershipGroup } from '@/components/campaign/dashboard/HomeSearchLeadershipGroup'
|
||||
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
|
||||
import { HomeSearchResultsLayout } from '@/components/campaign/dashboard/HomeSearchResultsLayout'
|
||||
import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
|
||||
import { HomeSearchStateDeputyGroup } from '@/components/campaign/dashboard/HomeSearchStateDeputyGroup'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
||||
import { loadCampaignHomeSummary } from '@/utilities/campaignDashboardData'
|
||||
|
|
@ -47,22 +39,7 @@ export default async function CampaignHomePage() {
|
|||
return (
|
||||
<CampaignPageShell aria-label="Início" className="h-full min-h-0">
|
||||
{staff ? (
|
||||
<CampaignHomeStaffChrome
|
||||
actions={actions}
|
||||
searchResults={
|
||||
<HomeSearchResultsShell>
|
||||
<HomeSearchResultsLayout>
|
||||
<HomeSearchMunicipalityGroup />
|
||||
<HomeSearchLeadershipGroup />
|
||||
<HomeSearchAdvisorGroup />
|
||||
<HomeSearchActivityGroup />
|
||||
<HomeSearchStateDeputyGroup />
|
||||
<HomeSearchDemandGroup />
|
||||
</HomeSearchResultsLayout>
|
||||
</HomeSearchResultsShell>
|
||||
}
|
||||
summarySlot={summarySlot}
|
||||
/>
|
||||
<CampaignHomeStaffChrome actions={actions} summarySlot={summarySlot} />
|
||||
) : (
|
||||
<CampaignHomeLayout actions={actions} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
|
|||
|
||||
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
|
||||
import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHomeSearch'
|
||||
import { CampaignStaffGlobalSearchBody } from '@/components/campaign/dashboard/CampaignStaffGlobalSearch'
|
||||
import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext'
|
||||
import {
|
||||
HomeSearchResultsProvider,
|
||||
|
|
@ -29,7 +30,7 @@ export const CampaignHomeStaffChrome = ({
|
|||
summarySlot,
|
||||
}: {
|
||||
actions: ReactNode
|
||||
/** Result groups plug in here from B48+. */
|
||||
/** Result groups plug in here from B48+. Defaults to global search when omitted. */
|
||||
searchResults?: ReactNode
|
||||
summarySlot?: ReactNode
|
||||
}) => {
|
||||
|
|
@ -46,7 +47,7 @@ export const CampaignHomeStaffChrome = ({
|
|||
searchResults ? (
|
||||
<CampaignHomeStaffSearchSlot searchResults={searchResults} />
|
||||
) : (
|
||||
<CampaignHomeSearch />
|
||||
<CampaignStaffGlobalSearchBody />
|
||||
)
|
||||
}
|
||||
summarySlot={summarySlot}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
'use client'
|
||||
|
||||
import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHomeSearch'
|
||||
import { HomeSearchActivityGroup } from '@/components/campaign/dashboard/HomeSearchActivityGroup'
|
||||
import { HomeSearchAdvisorGroup } from '@/components/campaign/dashboard/HomeSearchAdvisorGroup'
|
||||
import { HomeSearchDemandGroup } from '@/components/campaign/dashboard/HomeSearchDemandGroup'
|
||||
import { HomeSearchLeadershipGroup } from '@/components/campaign/dashboard/HomeSearchLeadershipGroup'
|
||||
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
|
||||
import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext'
|
||||
import { HomeSearchResultsLayout } from '@/components/campaign/dashboard/HomeSearchResultsLayout'
|
||||
import {
|
||||
HomeSearchResultsProvider,
|
||||
useHomeSearchResultsState,
|
||||
} from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
|
||||
import { HomeSearchStateDeputyGroup } from '@/components/campaign/dashboard/HomeSearchStateDeputyGroup'
|
||||
import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearchQuery'
|
||||
|
||||
const CampaignStaffGlobalSearchResults = () => {
|
||||
const searchResultsState = useHomeSearchResultsState()
|
||||
|
||||
return (
|
||||
<CampaignHomeSearch resultsBusy={searchResultsState.isFetching}>
|
||||
<HomeSearchResultsProvider value={searchResultsState}>
|
||||
<HomeSearchResultsShell>
|
||||
<HomeSearchResultsLayout>
|
||||
<HomeSearchMunicipalityGroup />
|
||||
<HomeSearchLeadershipGroup />
|
||||
<HomeSearchAdvisorGroup />
|
||||
<HomeSearchActivityGroup />
|
||||
<HomeSearchStateDeputyGroup />
|
||||
<HomeSearchDemandGroup />
|
||||
</HomeSearchResultsLayout>
|
||||
</HomeSearchResultsShell>
|
||||
</HomeSearchResultsProvider>
|
||||
</CampaignHomeSearch>
|
||||
)
|
||||
}
|
||||
|
||||
/** Result groups + input — requires an ancestor `HomeSearchProvider`. */
|
||||
export const CampaignStaffGlobalSearchBody = CampaignStaffGlobalSearchResults
|
||||
|
||||
/** Staff global search — same contract as Início (`POST /campanha/home-search`). */
|
||||
export const CampaignStaffGlobalSearch = () => {
|
||||
const searchState = useHomeSearchQuery()
|
||||
|
||||
return (
|
||||
<HomeSearchProvider value={searchState}>
|
||||
<CampaignStaffGlobalSearchResults />
|
||||
</HomeSearchProvider>
|
||||
)
|
||||
}
|
||||
27
src/components/campaign/shell/CampaignAppScrollChrome.tsx
Normal file
27
src/components/campaign/shell/CampaignAppScrollChrome.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
CampaignContentScroll,
|
||||
CampaignQuickActionsHost,
|
||||
useQuickActionsChromeActive,
|
||||
} from '@/components/campaign/shell/CampaignQuickActionsHost'
|
||||
import type { CampaignRole } from '@/lib/campaignRoles'
|
||||
|
||||
export const CampaignAppScrollChrome = ({
|
||||
role,
|
||||
children,
|
||||
}: {
|
||||
role: CampaignRole
|
||||
children: ReactNode
|
||||
}) => {
|
||||
const quickActionsPeek = useQuickActionsChromeActive(role)
|
||||
|
||||
return (
|
||||
<>
|
||||
<CampaignContentScroll quickActionsPeek={quickActionsPeek}>{children}</CampaignContentScroll>
|
||||
<CampaignQuickActionsHost role={role} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
55
src/components/campaign/shell/CampaignQuickActionContext.tsx
Normal file
55
src/components/campaign/shell/CampaignQuickActionContext.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
} from 'react'
|
||||
|
||||
import {
|
||||
emptyCampaignQuickActionContext,
|
||||
type CampaignQuickActionContext,
|
||||
} from '@/lib/campaignQuickActionContext'
|
||||
|
||||
type CampaignQuickActionContextValue = {
|
||||
context: CampaignQuickActionContext
|
||||
setContext: Dispatch<SetStateAction<CampaignQuickActionContext>>
|
||||
}
|
||||
|
||||
const QuickActionContext = createContext<CampaignQuickActionContextValue | null>(null)
|
||||
|
||||
export const CampaignQuickActionContextProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [context, setContext] = useState<CampaignQuickActionContext>(emptyCampaignQuickActionContext)
|
||||
const value = useMemo(() => ({ context, setContext }), [context])
|
||||
|
||||
return <QuickActionContext.Provider value={value}>{children}</QuickActionContext.Provider>
|
||||
}
|
||||
|
||||
export const useCampaignQuickActionContext = (): CampaignQuickActionContextValue => {
|
||||
const value = useContext(QuickActionContext)
|
||||
if (!value) {
|
||||
throw new Error('CampaignQuickActionContextProvider is required')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** B80+ — set page context for the current route (cleared on unmount). */
|
||||
export const useSetCampaignQuickActionContext = (next: CampaignQuickActionContext): void => {
|
||||
const { setContext } = useCampaignQuickActionContext()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setContext(next)
|
||||
}, [next, setContext])
|
||||
|
||||
useLayoutEffect(
|
||||
() => () => {
|
||||
setContext(emptyCampaignQuickActionContext())
|
||||
},
|
||||
[setContext],
|
||||
)
|
||||
}
|
||||
110
src/components/campaign/shell/CampaignQuickActionsDrawer.tsx
Normal file
110
src/components/campaign/shell/CampaignQuickActionsDrawer.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
import { CampaignHomeActionStrip } from '@/components/campaign/dashboard/CampaignHomeActionStrip'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
} from '@/components/ui/Drawer'
|
||||
import type { ResolvedCampaignHomeAction } from '@/lib/campaignHomeActions'
|
||||
import {
|
||||
QUICK_ACTIONS_SNAP_COLLAPSED,
|
||||
QUICK_ACTIONS_SNAP_EXPANDED,
|
||||
QUICK_ACTIONS_SNAP_POINTS,
|
||||
quickActionsSnapIsExpanded,
|
||||
type QuickActionsSnapPoint,
|
||||
} from '@/lib/campaignQuickActionSnap'
|
||||
import type { QuickActionsSnapPoint,
|
||||
} from '@/lib/campaignQuickActionSnap'
|
||||
|
||||
const SNAP_POINTS = [...QUICK_ACTIONS_SNAP_POINTS]
|
||||
|
||||
export const CampaignQuickActionsDrawer = ({
|
||||
actions,
|
||||
}: {
|
||||
actions: readonly ResolvedCampaignHomeAction[]
|
||||
}) => {
|
||||
const [snapPoint, setSnapPoint] = useState<QuickActionsSnapPoint | null>(
|
||||
QUICK_ACTIONS_SNAP_COLLAPSED,
|
||||
)
|
||||
const expanded = quickActionsSnapIsExpanded(snapPoint)
|
||||
const showActions = actions.length > 0
|
||||
|
||||
const handleSnapPointChange = useCallback((next: QuickActionsSnapPoint | string | number | null) => {
|
||||
if (next === QUICK_ACTIONS_SNAP_COLLAPSED || next === QUICK_ACTIONS_SNAP_EXPANDED) {
|
||||
setSnapPoint(next)
|
||||
return
|
||||
}
|
||||
setSnapPoint(QUICK_ACTIONS_SNAP_COLLAPSED)
|
||||
}, [])
|
||||
|
||||
const toggleSnap = useCallback(() => {
|
||||
setSnapPoint((current) =>
|
||||
quickActionsSnapIsExpanded(current) ? QUICK_ACTIONS_SNAP_COLLAPSED : QUICK_ACTIONS_SNAP_EXPANDED,
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setSnapPoint(QUICK_ACTIONS_SNAP_COLLAPSED)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open
|
||||
modal={false}
|
||||
swipeDirection="down"
|
||||
snapPoints={SNAP_POINTS}
|
||||
snapPoint={snapPoint}
|
||||
onSnapPointChange={handleSnapPointChange}
|
||||
onOpenChange={handleOpenChange}
|
||||
disablePointerDismissal
|
||||
>
|
||||
<DrawerContent
|
||||
id="CampaignQuickActionsDrawer"
|
||||
className={cn(
|
||||
'border-t border-border bg-background text-foreground shadow-[0_-4px_24px_-8px_rgb(0_0_0/0.12)] print:hidden',
|
||||
!expanded && 'pointer-events-auto',
|
||||
)}
|
||||
>
|
||||
<DrawerTitle className="sr-only">Ações rápidas</DrawerTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSnap}
|
||||
aria-expanded={expanded}
|
||||
aria-controls="quickActionContext"
|
||||
className="flex w-full shrink-0 cursor-grab flex-col items-center border-0 bg-transparent px-4 pt-2 pb-1 active:cursor-grabbing"
|
||||
aria-label={expanded ? 'Ocultar ações rápidas' : 'Mostrar ações rápidas'}
|
||||
>
|
||||
<span aria-hidden className="mb-1 block h-1 w-12 rounded-full bg-muted" />
|
||||
</button>
|
||||
<div
|
||||
id="quickActionContext"
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto overscroll-contain px-4 pb-[max(1rem,env(safe-area-inset-bottom,0px))]',
|
||||
!expanded && 'hidden',
|
||||
)}
|
||||
data-snap={expanded ? 'expanded' : 'collapsed'}
|
||||
>
|
||||
{showActions ? (
|
||||
<CampaignHomeActionStrip
|
||||
actions={actions.map((action) => ({
|
||||
id: action.id,
|
||||
label: action.label,
|
||||
icon: action.icon,
|
||||
description: action.description,
|
||||
href: action.href,
|
||||
}))}
|
||||
className="w-full"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Nenhuma ação rápida nesta página.</p>
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
55
src/components/campaign/shell/CampaignQuickActionsHost.tsx
Normal file
55
src/components/campaign/shell/CampaignQuickActionsHost.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { CampaignQuickActionsDrawer } from '@/components/campaign/shell/CampaignQuickActionsDrawer'
|
||||
import { useCampaignQuickActionContext } from '@/components/campaign/shell/CampaignQuickActionContext'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry'
|
||||
import { shouldMountQuickActionsDrawer } from '@/lib/campaignQuickActionMount'
|
||||
import { QUICK_ACTIONS_SNAP_COLLAPSED } from '@/lib/campaignQuickActionSnap'
|
||||
import type { CampaignRole } from '@/lib/campaignRoles'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const CampaignContentScroll = ({
|
||||
children,
|
||||
quickActionsPeek,
|
||||
}: {
|
||||
children: ReactNode
|
||||
quickActionsPeek: boolean
|
||||
}) => (
|
||||
<div
|
||||
data-slot="campaign-content-scroll"
|
||||
className={cn(
|
||||
'min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 md:p-6 print:h-auto print:overflow-visible print:p-0',
|
||||
quickActionsPeek && 'pb-[calc(1rem+var(--campaign-quick-actions-peek))] md:pb-6',
|
||||
)}
|
||||
style={
|
||||
quickActionsPeek
|
||||
? ({ '--campaign-quick-actions-peek': QUICK_ACTIONS_SNAP_COLLAPSED } as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const CampaignQuickActionsHost = ({ role }: { role: CampaignRole }) => {
|
||||
const pathname = usePathname()
|
||||
const isMobile = useIsMobile()
|
||||
const { context } = useCampaignQuickActionContext()
|
||||
|
||||
const mounted = isMobile && shouldMountQuickActionsDrawer(pathname, role)
|
||||
if (!mounted) return null
|
||||
|
||||
const actions = resolveQuickActionsForPath(pathname, role, context)
|
||||
|
||||
return <CampaignQuickActionsDrawer role={role} actions={actions} />
|
||||
}
|
||||
|
||||
export const useQuickActionsChromeActive = (role: CampaignRole): boolean => {
|
||||
const pathname = usePathname()
|
||||
const isMobile = useIsMobile()
|
||||
return isMobile && shouldMountQuickActionsDrawer(pathname, role)
|
||||
}
|
||||
13
src/lib/campaignQuickActionContext.ts
Normal file
13
src/lib/campaignQuickActionContext.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* Client-safe quick-action context — routes and B80+ providers fill this so
|
||||
* `resolveQuickActionsForPath` can prefill wizard/list hrefs.
|
||||
*/
|
||||
export type CampaignQuickActionContext = {
|
||||
municipalitySlug?: string
|
||||
leadershipId?: number
|
||||
organizationSlug?: string
|
||||
activitySlug?: string
|
||||
demandSlug?: string
|
||||
}
|
||||
|
||||
export const emptyCampaignQuickActionContext = (): CampaignQuickActionContext => ({})
|
||||
27
src/lib/campaignQuickActionMount.ts
Normal file
27
src/lib/campaignQuickActionMount.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
|
||||
import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes'
|
||||
import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles'
|
||||
|
||||
/** Início matches only exactly — same rule as sidebar nav. */
|
||||
export const isCampaignHomePath = (pathname: string): boolean =>
|
||||
pathname === CAMPAIGN_HOME || pathname === `${CAMPAIGN_HOME}/`
|
||||
|
||||
export const isCampaignActionsPath = (pathname: string): boolean =>
|
||||
pathname === CAMPAIGN_ACTIONS_HOME || pathname.startsWith(`${CAMPAIGN_ACTIONS_HOME}/`)
|
||||
|
||||
export const isLeaderContactsPath = (pathname: string): boolean =>
|
||||
pathname === LEADER_CONTACTS_HOME || pathname.startsWith(`${LEADER_CONTACTS_HOME}/`)
|
||||
|
||||
/**
|
||||
* Whether the mobile quick-actions drawer should mount on this navigation.
|
||||
* Desktop (`md+`) is gated in the host via `useIsMobile`.
|
||||
*/
|
||||
export const shouldMountQuickActionsDrawer = (
|
||||
pathname: string,
|
||||
role: CampaignRole,
|
||||
): boolean => {
|
||||
if (isCampaignHomePath(pathname)) return false
|
||||
if (isCampaignActionsPath(pathname)) return false
|
||||
if (role === 'leader') return isLeaderContactsPath(pathname)
|
||||
return isStaffCampaignRole(role)
|
||||
}
|
||||
14
src/lib/campaignQuickActionRegistry.ts
Normal file
14
src/lib/campaignQuickActionRegistry.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { ResolvedCampaignHomeAction } from '@/lib/campaignHomeActions'
|
||||
import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext'
|
||||
import type { CampaignRole } from '@/lib/campaignRoles'
|
||||
|
||||
/**
|
||||
* Pathname + role + page context → contextual quick actions.
|
||||
* Catalogs per vertical land in B80–B90; the B79 chassis renders whatever
|
||||
* this helper returns (empty until those items register providers).
|
||||
*/
|
||||
export const resolveQuickActionsForPath = (
|
||||
_pathname: string,
|
||||
_role: CampaignRole,
|
||||
_context: CampaignQuickActionContext,
|
||||
): readonly ResolvedCampaignHomeAction[] => []
|
||||
15
src/lib/campaignQuickActionSnap.ts
Normal file
15
src/lib/campaignQuickActionSnap.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/** Collapsed peek — swipe handle only (replaces legacy bottom-nav `pb-24`). */
|
||||
export const QUICK_ACTIONS_SNAP_COLLAPSED = '3rem' as const
|
||||
|
||||
/** Expanded — actions strip + global search inside the drawer. */
|
||||
export const QUICK_ACTIONS_SNAP_EXPANDED = 0.55 as const
|
||||
|
||||
export const QUICK_ACTIONS_SNAP_POINTS = [
|
||||
QUICK_ACTIONS_SNAP_COLLAPSED,
|
||||
QUICK_ACTIONS_SNAP_EXPANDED,
|
||||
] as const
|
||||
|
||||
export type QuickActionsSnapPoint = (typeof QUICK_ACTIONS_SNAP_POINTS)[number]
|
||||
|
||||
export const quickActionsSnapIsExpanded = (snap: QuickActionsSnapPoint | null): boolean =>
|
||||
snap === QUICK_ACTIONS_SNAP_EXPANDED
|
||||
|
|
@ -124,6 +124,10 @@ describe('campaign visual foundation', () => {
|
|||
resolve(process.cwd(), 'src/app/(campaign)/campanha/(app)/layout.tsx'),
|
||||
'utf8',
|
||||
)
|
||||
const scrollChromeSource = readFileSync(
|
||||
resolve(process.cwd(), 'src/components/campaign/shell/CampaignQuickActionsHost.tsx'),
|
||||
'utf8',
|
||||
)
|
||||
const sidebarSource = readFileSync(
|
||||
resolve(process.cwd(), 'src/components/campaign/shell/CampaignSidebar.tsx'),
|
||||
'utf8',
|
||||
|
|
@ -132,8 +136,8 @@ describe('campaign visual foundation', () => {
|
|||
expect(layoutSource).toContain(
|
||||
'className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible"',
|
||||
)
|
||||
expect(layoutSource).toContain('data-slot="campaign-content-scroll"')
|
||||
expect(layoutSource).toContain('min-h-0 flex-1 overflow-y-auto overscroll-contain')
|
||||
expect(scrollChromeSource).toContain('data-slot="campaign-content-scroll"')
|
||||
expect(scrollChromeSource).toContain('min-h-0 flex-1 overflow-y-auto overscroll-contain')
|
||||
expect(layoutSource).toContain(
|
||||
'hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden',
|
||||
)
|
||||
|
|
|
|||
63
tests/unit/campaignQuickAction.unit.spec.ts
Normal file
63
tests/unit/campaignQuickAction.unit.spec.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
isCampaignActionsPath,
|
||||
isCampaignHomePath,
|
||||
isLeaderContactsPath,
|
||||
shouldMountQuickActionsDrawer,
|
||||
} from '@/lib/campaignQuickActionMount'
|
||||
import { resolveQuickActionsForPath } from '@/lib/campaignQuickActionRegistry'
|
||||
import {
|
||||
QUICK_ACTIONS_SNAP_COLLAPSED,
|
||||
QUICK_ACTIONS_SNAP_EXPANDED,
|
||||
quickActionsSnapIsExpanded,
|
||||
} from '@/lib/campaignQuickActionSnap'
|
||||
|
||||
describe('campaignQuickActionMount', () => {
|
||||
it('treats Início as exact match only', () => {
|
||||
expect(isCampaignHomePath('/campanha')).toBe(true)
|
||||
expect(isCampaignHomePath('/campanha/')).toBe(true)
|
||||
expect(isCampaignHomePath('/campanha/municipios')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches action wizard subtree', () => {
|
||||
expect(isCampaignActionsPath('/campanha/acoes')).toBe(true)
|
||||
expect(isCampaignActionsPath('/campanha/acoes/atualizar-votos')).toBe(true)
|
||||
expect(isCampaignActionsPath('/campanha/municipios')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches leader contacts subtree', () => {
|
||||
expect(isLeaderContactsPath('/campanha/contatos')).toBe(true)
|
||||
expect(isLeaderContactsPath('/campanha/contatos/novo')).toBe(true)
|
||||
expect(isLeaderContactsPath('/campanha')).toBe(false)
|
||||
})
|
||||
|
||||
it('mounts for staff outside Início and acoes', () => {
|
||||
expect(shouldMountQuickActionsDrawer('/campanha/municipios', 'coordinator')).toBe(true)
|
||||
expect(shouldMountQuickActionsDrawer('/campanha', 'coordinator')).toBe(false)
|
||||
expect(shouldMountQuickActionsDrawer('/campanha/acoes/registrar-sinal', 'advisor')).toBe(false)
|
||||
})
|
||||
|
||||
it('mounts for leader only on contacts', () => {
|
||||
expect(shouldMountQuickActionsDrawer('/campanha/contatos', 'leader')).toBe(true)
|
||||
expect(shouldMountQuickActionsDrawer('/campanha/municipios', 'leader')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('campaignQuickActionRegistry', () => {
|
||||
it('returns empty catalog until B80+ register providers', () => {
|
||||
expect(
|
||||
resolveQuickActionsForPath('/campanha/municipios/foo', 'coordinator', {
|
||||
municipalitySlug: 'foo',
|
||||
}),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('campaignQuickActionSnap', () => {
|
||||
it('detects expanded snap', () => {
|
||||
expect(quickActionsSnapIsExpanded(QUICK_ACTIONS_SNAP_EXPANDED)).toBe(true)
|
||||
expect(quickActionsSnapIsExpanded(QUICK_ACTIONS_SNAP_COLLAPSED)).toBe(false)
|
||||
expect(quickActionsSnapIsExpanded(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue