Compare commits

...

22 commits

Author SHA1 Message Date
08f1743557 fix(activities): relabel "Get invoice" → "Proceed" on PurchaseTicket
The lightning rail's CTA in PurchaseTicketDialog now reads
"Proceed" (or "Proceed (N tickets)" for multi-quantity) instead
of "Get invoice". Matches the language used on the fiat rails
("Continue to Stripe checkout" etc.) and reads as a generic
forward action regardless of which payment path the user picks.
2026-06-06 21:18:50 +00:00
148e92b7aa fix(activities): drop duplicate empty-state line on ActivityList
The empty state rendered "No activities found" twice — once as the
heading and once as the description below, because
`activities.noActivities` and `activities.search.noResults`
translate to the same string in every locale. Drop the description
paragraph (and its mb-1 spacer on the heading).
2026-06-06 21:18:50 +00:00
2ea6afe487 feat(activities): fuzzy search on the Tickets roster
Adds a search box above the roster list that fuzzy-matches the
holder name and ticket id via the shared useFuzzySearch (Fuse.js)
composable. Empty query keeps the unregistered-first sort intact;
typing reorders by relevance. The empty-state message now
distinguishes "no tickets sold yet" from "no rows matched the
current query" so a busy roster + a typo doesn't look like
backend trouble.
2026-06-06 21:18:50 +00:00
d029660ef0 feat(activities): manual ticket registration from the roster tab
The "Scanned" tab becomes "Tickets" and now lists the full event
roster (sold tickets), not just the registered subset. Unregistered
rows lead the list with a Register button so the host can manually
mark someone present without a QR scan — e.g. lost phone, known in
person, or alternate proof of identity.

useTicketScanner gains registerManually(ticketId), which calls the
same PUT /tickets/register/{id} the scanner uses (so it inherits
the event-ownership gate and the unpaid/already-registered backend
checks), then refreshes stats. It skips the scanner pause + full-
screen banner since the operator initiated the action from the
list, and mirrors the session-local dedup so a subsequent QR scan
on the same ticket reports "Already scanned" instead of a duplicate
register round-trip.

The header now reads "registered / total · N to go" so the host
sees roster progress at a glance; failures from the manual register
surface as a sonner toast and the row reverts.
2026-06-06 21:18:50 +00:00
ef1acc17c3 revert: move scan counts back above the tabs + fix tab centering
Reverts 1aeea23 and folds in the actual fix the relocation was
chasing: the Scanner / Scanned tab labels were rendering with
their icons and text mis-aligned because TabsTrigger wraps its
slot in an inline `<span class="truncate">`. A `gap-1.5` on
TabsTrigger never reached the icon/label children. Wrap each
trigger's content in an `inline-flex items-center gap-1.5` span
so the icon and label share a real flex container.
2026-06-06 21:18:50 +00:00
933a166d05 feat(activities): move scan counts below the camera
The Scanned / Sold / Remaining strip moves out of the page header
to below the Tabs block. The camera (or scanned list, depending on
the active tab) stays prominent at the top; the counts read as a
summary footer instead of competing with the title for attention.
The stats-error notice follows the counts strip so the warning
stays adjacent to the values it affects.
2026-06-06 21:18:50 +00:00
a12ed8dd6a feat(activities): refine activity card for pending/rejected + compact
- Wash out pending/rejected events with opacity-50 + grayscale on a
  wrapper div so the operator sees at a glance the event isn't live,
  not just the small badge.
- Pull the status badge OUT of the wash-out wrapper and absolute-
  position it on Card root (bottom-2 left-2, z-10) so it stays in
  full color above the dim card. Both pending and rejected use the
  destructive token — the label text differentiates the two states.
  Bottom-left so it doesn't collide with the category chip on full
  cards or the thumbnail on compact ones.
- Compact rows in the Hosting view now show a small left-aligned
  thumbnail (w-20 h-20, self-center, ml-3, rounded-md) when the
  event carries an image — host can still recognize each event at a
  glance without paying the visual weight of a full hero.
- Card root becomes `relative overflow-hidden`; the wrapper div
  owns the conditional flex-row (compact) / flex-col (default)
  layout and the opacity/grayscale toggling.
2026-06-06 21:18:50 +00:00
5517aebb6a feat(activities): My tickets toggle on the calendar view
Adds a small filter chip above the month grid that, when on, limits
the calendar to events the signed-in user holds at least one paid
ticket for (intersecting ownedActivityIds from useOwnedTickets).
Hidden when logged out — nothing to own. Left-aligned so it
doesn't collide with the fixed top-right hamburger menu.

State is local to the page on purpose: narrowing the calendar
shouldn't also narrow the feed when the user navigates back.
2026-06-06 21:18:50 +00:00
0f133119c4 feat(activities): tailor Hosting tab + host detail view for operators
Hosting feed (ActivitiesPage when onlyHosting):
- Hide the date picker strip + calendar shortcut and the entire
  Filters/temporal-pills row; an operator managing their roster
  doesn't need calendar navigation or temporal narrowing.
- Keep the search bar — finding a specific event in a long roster
  still matters.
- Render compact cards via a new `compact` prop on ActivityList +
  ActivityCard: no hero image, single-line title, no summary, no
  bookmark, no "Yours" badge (every card is the operator's own),
  tighter p-3 padding, single-column flex layout.

Host detail view (ActivityDetailPage when ownedLnbitsEvent):
- Drop the top-bar Scan and Edit buttons. Edit moves into the title
  row as a prominent filled-primary icon button right of the title;
  Scan moves into the tickets section.
- Render a full-width "Scan tickets" CTA in place of Buy ticket, and
  hoist it outside the ticketInfo gate so it appears even on hosted
  events that were published without AIO ticket tags.
- Hide BookmarkButton and RSVPButton for the host (favoriting /
  RSVPing your own event are noise affordances).

Detail-page badge row: "Yours" leads the row in the highlighted
secondary variant; category and tags drop to outline so the
ownership signal stands out.
2026-06-06 21:18:50 +00:00
5aced3f2c9 fix(activities): share filter refs across useActivities consumers
useActivityFilters allocated a fresh set of refs on every call, so
when activities-app/App.vue (Hosting bottom-nav tab) and
ActivitiesPage.vue each invoked useActivities(), they got
independent onlyHosting/temporal/etc state. Tapping Hosting toggled
the App.vue ref; the page never saw the change. Hoist the filter
refs to module scope so every consumer shares the same instance.
2026-06-06 21:18:50 +00:00
78e3d56d76 feat(activities): restructure bottom nav around Home/MyTickets/Hosting
The bottom-nav tabs become Home, My tickets, Hosting, Map, Favorites.
- Feed is relabeled "Home" (en/fr; es was already "Inicio").
- My tickets and Hosting move out of the sidebar menu back into the
  bottom nav. Hosting is a synthetic tab — no path of its own; it
  toggles the existing onlyHosting feed filter and lands on
  /activities, with Home as the inverse (clears the filter on tap).
- Calendar leaves the bottom nav. The week strip now ends with a
  small calendar icon button that routes to /activities/calendar,
  so the entry point sits adjacent to the date UI instead of
  competing for a tab slot.
- Create activity leaves the bottom nav too. A full-width "+ Create
  activity" CTA appears at the top of the feed only when the Hosting
  tab is active, so the Create entry point lives inside the section
  it belongs to.

BottomTab gains an optional `isActive()` predicate so tabs whose
active condition doesn't reduce to "current path starts with x"
(e.g. Hosting) can compute their own state.
2026-06-06 21:18:50 +00:00
a605b31c5f feat(activities): drop image placeholder when an event has no image
Cards without an image no longer render the solid-color 16:9
placeholder + calendar glyph. They go straight to the content area
with the badges (category, price, Yours, status, Past) shown
inline in a small row at the top, so the title and details aren't
pushed below a meaningless filler block.

The placeholderBg computed (hash → HSL) is removed; it was only
feeding the deleted no-image branch.
2026-06-06 21:18:50 +00:00
00959074da feat(activities): stationary Filters column next to scrolling pills
Filters icon + Clear-all sit in a stationary left-aligned column;
only the All/Today/Tomorrow/etc temporal pills scroll horizontally.
Clear-all is tucked tightly under the Filters icon (h-5, 10px text,
gap-0.5) and shows only when a filter is active. The badge no
longer lives inside the overflow-x scroll container, so the count
chip isn't clipped at the corner anymore.
2026-06-06 21:18:50 +00:00
b4dd7f2bbc feat(activities): reclaim vertical space above the feed
Past events no longer gets its own row — it folds into the existing
collapsible (renamed "Filters") alongside Categories, so the feed
gains that row by default. The Filters trigger badge counts past-
events being on plus any selected categories, so users still see at
a glance when hidden toggles are active.

The standalone "Filters active / Clear all" notice is gone too;
Clear all sits inline beside the trigger only when something's
active. Header is tightened (text-xl) and inter-row margins drop
from mb-4 to mb-3 across the date strip + temporal pills.
2026-06-06 21:18:50 +00:00
b19fa98d5b feat(webapp): replace HubPill with hamburger sidebar menu
The top-right "Back to hub" pill in each standalone is replaced by a
hamburger button that opens a right-side sheet reusing the existing
ProfileSheetContent (identity card, back-to-hub link, theme/lang/
currency prefs, profile settings or log-in CTA). The redundant
Profile entry is removed from BottomNav and its loggedOutOpensSheet
plumbing (BottomNav → AppShell) is dropped — Hub.vue still mounts
ProfileSheetTrigger directly so it's unaffected.

ProfileSheetContent gains an `app-nav` slot so standalones can inject
app-specific nav items above the cross-app section. AppShell exposes
a new optional `sidebarNav` prop that forwards items to the menu;
unset on non-activities standalones, those still get the hamburger
menu showing just the shared profile/preferences content.

Activities passes "My tickets" (routes to /my-tickets) and "Hosting"
(toggles the onlyHosting feed filter and lands on /activities), so
those entries leave the inline filter chip row on ActivitiesPage and
live in the sidebar instead. The "Past events" chip stays inline —
it doesn't require auth and pairs visually with the temporal filters.
2026-06-06 21:18:50 +00:00
fa1dff9de4 feat(activities): restructure event detail page layout
- Move bookmark heart from top bar to the right of the title.
- Replace the When/Where info cards with caption-style lines directly
  under the title (calendar + map-pin icons + muted text).
- Move description above the organizer so it sits right under the
  title/info separator; push the organizer card to the bottom.
- Promote the "you own N tickets" CTA (filled primary "View" button)
  and demote "Buy another ticket" to outline when the user already
  owns tickets, so the My-Tickets path is what jumps out.
- Tighten ticket availability against the buy button: standalone strip
  removed, count rendered as an xs muted caption directly under the
  buy CTA.
2026-06-06 21:18:50 +00:00
1f20d5f00c Merge pull request 'refactor(libra): redesign transactions list status + type encoding' (#93) from feat/libra-tx-status-encoding into dev
Reviewed-on: #93
2026-06-06 21:16:45 +00:00
75dfd8a541 refactor(libra): redesign transactions list status + type encoding
Rework how the standalone transactions list communicates entry status
and type so each visual channel does one job and the filter UI matches
the underlying axes.

Encoding:
- Type lives in the signed/colored amount (+green income, -red expense)
  and a matching Income/Expense badge in the badge row.
- Status lives in badges only: red Voided (leftmost) and yellow Pending
  (after the type badge). Cleared entries carry no status badge — the
  quiet default.
- Voided rows additionally strike-through and mute the amount.
- Drop the title-row status icons and the colored left border that
  previously fought with the amount color for the same meaning.

Filter UI:
- Replace the type radio + voided switch with three category chips —
  Income, Expenses, Voided — that independently toggle inclusion of one
  bucket of rows. Each row belongs to exactly one bucket (voided wins
  over type). Defaults: Income + Expenses on, Voided off.
- Empty-selection state nudges the user to enable a category.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 23:12:57 +02:00
4af220adda Merge pull request 'feat(libra): show voided transactions in standalone' (#92) from feat/libra-show-voided into dev
Reviewed-on: #92
2026-06-06 20:31:58 +00:00
1fbf7b3d26 fix(libra): exclude voided txs from balance Pending section
BalancePage filtered tx.flag === '!' to compute pending count/sum/list.
After the libra backend stops hiding voided transactions, those will
arrive with flag='!' plus a 'voided' tag and would otherwise leak into
the Pending section. Add the tag-aware exclusion to keep Pending
showing only genuinely pending entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 20:45:55 +02:00
e9195978c1 feat(libra): surface voided transactions in standalone history
Voided entries keep their '!' flag and gain a 'voided' tag per the libra
reject convention, so detecting them needs a tag check rather than a new
flag char. Render them inline with the existing 'x'-flag voided styling
(grey XCircle icon, strike-through title/amount, red-tinted Voided badge)
so users like Nancy can see their rejected entries instead of having them
silently disappear from the list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 20:45:25 +02:00
4c704e5a41 chore(expenses): delete orphaned admin permission UI
PermissionManager.vue and GrantPermissionDialog.vue were never
imported or routed anywhere; the three ExpensesAPI methods backing
them (listPermissions, grantPermission, revokePermission) pointed
at /libra/api/v1/permissions* which doesn't exist on the backend
(real path is /api/v1/admin/permissions*). The whole feature has
been unreachable since whenever the path drifted.

Removes the two components, the three API methods, and the four
types only they used (AccountPermission, GrantPermissionRequest,
AccountWithPermissions, PermissionType).

If cross-account permission management becomes a real need, the
backend at aiolabs/libra already provides the endpoints (now
correctly gated by require_super_user); rebuild the UI fresh
against the right paths rather than reviving this dead surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 23:17:52 +02:00
25 changed files with 844 additions and 1207 deletions

View file

@ -98,8 +98,11 @@ async function loadData() {
totalIncomeSats.value = balanceData.total_income_sats || 0
totalIncomeFiat.value = balanceData.total_income_fiat || {}
// Filter for pending transactions (flag = '!')
pendingTransactions.value = txData.entries.filter(tx => tx.flag === '!')
// Filter for pending transactions (flag = '!'), excluding voided ones
// (libra convention: voided keeps '!' flag and carries a 'voided' tag).
pendingTransactions.value = txData.entries.filter(
tx => tx.flag === '!' && !tx.tags?.includes('voided')
)
} catch (error) {
console.error('[BalancePage] Error loading data:', error)
toast.error('Failed to load balance data')

View file

@ -3,7 +3,7 @@ import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next'
import { Home, Map, Heart, Ticket, Megaphone } from 'lucide-vue-next'
import AppShell from '@/components/layout/AppShell.vue'
import type { BottomTab } from '@/components/layout/BottomNav.vue'
import { useAuth } from '@/composables/useAuthService'
@ -23,36 +23,71 @@ const activitiesStore = useActivitiesStore()
const { isAdmin, autoApprove } = useApprovalState()
// Used to merge own LNbits drafts into the activities feed right after
// the user creates or edits an event otherwise the new draft only
// surfaces on the next ActivitiesPage subscribe cycle.
const { loadOwnEvents } = useActivities()
// surfaces on the next ActivitiesPage subscribe cycle. `onlyHosting`
// is the feed filter that backs the Hosting bottom-nav tab tapping
// it toggles the filter on; Home tab toggles it off.
const { loadOwnEvents, onlyHosting, toggleHosting } = useActivities()
// True for /activities and its sub-routes (incl. detail pages) but
// not for the routes owned by other tabs (map/favorites). Used by
// both Home and Hosting active-state predicates so the highlight
// only shifts based on the onlyHosting flag while you're in the feed.
function inFeedRoute(): boolean {
if (route.path.startsWith('/activities/map')) return false
if (route.path.startsWith('/activities/favorites')) return false
return route.path === '/activities' || route.path.startsWith('/activities/')
}
// Settings dropped theme/lang/currency now live in the shared profile sheet.
// Create lives in the bottom nav: when logged out, tapping it shows an
// auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of
// opening the dialog. Per-app placement deliberation tracked at #53.
const tabs = computed<BottomTab[]>(() => [
{ name: t('activities.nav.feed'), icon: Search, path: '/activities' },
{ name: t('activities.nav.calendar'), icon: CalendarDays, path: '/activities/calendar' },
{
name: t('activities.createNew'),
icon: Plus,
name: t('activities.nav.feed'),
icon: Home,
onClick: () => {
// Tapping Home clears the hosting filter so the feed always
// returns to the unfiltered view, regardless of where the
// user just came from.
if (onlyHosting.value) toggleHosting()
if (route.path !== '/activities') router.push('/activities')
},
isActive: () => inFeedRoute() && !onlyHosting.value,
},
{
name: t('activities.filters.myTickets'),
icon: Ticket,
path: '/my-tickets',
onClick: () => {
if (!isAuthenticated.value) {
toast.info('Log in to create an activity', {
toast.info(t('activities.detail.loginToBuyTickets'), {
action: {
label: 'Log in',
label: t('activities.detail.logIn'),
onClick: () => router.push('/login'),
},
})
return
}
// Defensively clear any lingering edit selection so the Create
// tap always opens in Create mode regardless of a prior Edit.
activitiesStore.editingEvent = null
activitiesStore.showCreateDialog = true
router.push('/my-tickets')
},
disabled: !isAuthenticated.value,
},
{
name: t('activities.filters.hosting'),
icon: Megaphone,
onClick: () => {
if (!isAuthenticated.value) {
toast.info(t('activities.hosting.loginPrompt', 'Log in to manage your hosted activities'), {
action: {
label: t('activities.favorites.logIn'),
onClick: () => router.push('/login'),
},
})
return
}
if (!onlyHosting.value) toggleHosting()
if (route.path !== '/activities') router.push('/activities')
},
isActive: () => inFeedRoute() && onlyHosting.value,
disabled: !isAuthenticated.value,
},
{ name: t('activities.nav.map'), icon: Map, path: '/activities/map' },
{
name: t('activities.nav.favorites'),
@ -77,18 +112,8 @@ const tabs = computed<BottomTab[]>(() => [
},
])
// Feed tab is active for the bare /activities route AND all sub-paths that
// aren't owned by another tab (e.g. /activities/<id> detail pages).
// Path-based fallback for tabs that don't carry their own `isActive`.
function isActive(path: string): boolean {
if (path === '/activities') {
return (
route.path === '/activities' ||
(route.path.startsWith('/activities/') &&
!route.path.startsWith('/activities/calendar') &&
!route.path.startsWith('/activities/map') &&
!route.path.startsWith('/activities/favorites'))
)
}
return route.path.startsWith(path)
}

View file

@ -4,24 +4,23 @@ import { useRoute } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import { useTheme } from '@/components/theme-provider'
import BottomNav, { type BottomTab } from './BottomNav.vue'
import HubPill from './HubPill.vue'
import StandaloneMenu, { type SidebarNavItem } from './StandaloneMenu.vue'
interface Props {
/** App-specific tabs displayed before the constant Profile entry. */
tabs: BottomTab[]
/** Active-tab matcher. Forwarded to BottomNav. */
isActive: (path: string) => boolean
/** Hide the top-right HubPill only true when this shell is rendering
* the hub itself. Standalones leave this false (default). */
/** Hide the top-right standalone menu only true when this shell is
* rendering the hub itself. Standalones leave this false (default). */
hideHub?: boolean
/** Forwarded to BottomNav. Hub passes true so logged-out users can still
* reach prefs from the sheet. Standalones leave it false. */
loggedOutOpensSheet?: boolean
/** App-specific nav items rendered at the top of the standalone menu. */
sidebarNav?: SidebarNavItem[]
}
const props = withDefaults(defineProps<Props>(), {
hideHub: false,
loggedOutOpensSheet: false,
sidebarNav: () => [],
})
const route = useRoute()
@ -45,11 +44,13 @@ const isLoginPage = computed(() => route.path === '/login')
v-if="!isLoginPage"
:tabs="props.tabs"
:is-active="props.isActive"
:logged-out-opens-sheet="props.loggedOutOpensSheet"
/>
</div>
<HubPill v-if="!props.hideHub && !isLoginPage" />
<StandaloneMenu
v-if="!props.hideHub && !isLoginPage"
:items="props.sidebarNav"
/>
<Toaster />
<!-- Default slot for shell-level overlays (dialogs, sheets, etc.) that

View file

@ -1,7 +1,6 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import type { Component } from 'vue'
import ProfileSheetTrigger from './ProfileSheetTrigger.vue'
export interface BottomTab {
/** Translated label shown under the icon. */
@ -18,6 +17,11 @@ export interface BottomTab {
/** Render the entry ghosted (opacity-reduced). Used for coming-soon and
* for auth-required tabs when the user is logged out. */
disabled?: boolean
/** Per-tab active-state override for entries whose active condition
* doesn't reduce to "current route starts with this.path" e.g. a
* "Hosting" tab that is active when a feed-filter ref is on. When
* set it wins over the App-level `isActive(path)` matcher. */
isActive?: () => boolean
}
interface Props {
@ -25,13 +29,9 @@ interface Props {
/** Active-tab matcher. Each app has its own nesting rules so we don't try
* to derive a one-size-fits-all default consumer supplies the function. */
isActive: (path: string) => boolean
/** When true (Hub), the unauthenticated profile button still opens the
* sheet so logged-out users can change theme/lang. When false (standalones),
* unauth profile button routes straight to /login. */
loggedOutOpensSheet?: boolean
}
const props = withDefaults(defineProps<Props>(), { loggedOutOpensSheet: false })
const props = defineProps<Props>()
const router = useRouter()
@ -42,6 +42,11 @@ function onTabClick(tab: BottomTab) {
}
if (tab.path) router.push(tab.path)
}
function isTabActive(tab: BottomTab): boolean {
if (tab.isActive) return tab.isActive()
return !!tab.path && props.isActive(tab.path)
}
</script>
<template>
@ -56,12 +61,12 @@ function onTabClick(tab: BottomTab) {
:key="tab.name"
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
:class="[
tab.path && props.isActive(tab.path)
isTabActive(tab)
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
tab.disabled ? 'opacity-50' : '',
]"
:aria-current="tab.path && props.isActive(tab.path) ? 'page' : undefined"
:aria-current="isTabActive(tab) ? 'page' : undefined"
@click="onTabClick(tab)"
>
<component :is="tab.icon" class="w-5 h-5" />
@ -73,10 +78,6 @@ function onTabClick(tab: BottomTab) {
{{ tab.badge > 99 ? '99+' : tab.badge }}
</span>
</button>
<!-- Always-on Profile entry, appended on the right. Consumers don't
pass it; the shell owns it so it's identical across every app. -->
<ProfileSheetTrigger :logged-out-opens-sheet="props.loggedOutOpensSheet" />
</div>
</nav>
</template>

View file

@ -1,24 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Home } from 'lucide-vue-next'
const { t } = useI18n()
/** Falls back to '/' for path-mount deployments where the hub root is the
* same origin. Set VITE_HUB_ROOT_URL to a full URL for subdomain
* deployments where the hub lives on a sibling origin. */
const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
</script>
<template>
<a
:href="hubRootUrl"
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center gap-1.5 rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
:aria-label="t('common.nav.backToHub')"
>
<Home class="w-3.5 h-3.5" />
<span class="hidden sm:inline">{{ t('common.nav.hub') }}</span>
</a>
</template>

View file

@ -53,6 +53,9 @@ function goLogin() {
</div>
</div>
<!-- App-specific nav items (rendered by callers like StandaloneMenu) -->
<slot name="app-nav" />
<!-- Cross-app links + global preferences (always visible, auth or not) -->
<div class="mt-4">
<a

View file

@ -0,0 +1,81 @@
<script setup lang="ts">
import { ref, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { Menu } from 'lucide-vue-next'
import {
Sheet,
SheetContent,
SheetTrigger,
} from '@/components/ui/sheet'
import { Separator } from '@/components/ui/separator'
import ProfileSheetContent from './ProfileSheetContent.vue'
export interface SidebarNavItem {
/** Display label. */
name: string
/** Lucide (or any) component to render as the leading icon. */
icon: Component
/** Optional route to navigate to on click. */
path?: string
/** Optional click handler. Runs after navigation if both are set. */
onClick?: () => void
/** Visual-only "active" predicate for highlight state. */
isActive?: () => boolean
}
interface Props {
/** App-specific nav items rendered at the top of the sheet. */
items?: SidebarNavItem[]
}
const props = withDefaults(defineProps<Props>(), { items: () => [] })
const { t } = useI18n()
const router = useRouter()
const open = ref(false)
function handleClick(item: SidebarNavItem) {
if (item.path) router.push(item.path)
item.onClick?.()
open.value = false
}
</script>
<template>
<Sheet v-model:open="open">
<SheetTrigger as-child>
<button
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center justify-center rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
:aria-label="t('common.nav.menu')"
>
<Menu class="w-5 h-5" />
</button>
</SheetTrigger>
<SheetContent side="right" class="w-80 sm:w-96 overflow-y-auto">
<ProfileSheetContent>
<template v-if="props.items.length" #app-nav>
<nav class="mt-4 space-y-1">
<button
v-for="item in props.items"
:key="item.name"
type="button"
:class="[
item.isActive?.()
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
'group flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
]"
@click="handleClick(item)"
>
<component :is="item.icon" class="h-5 w-5 shrink-0" />
{{ item.name }}
</button>
</nav>
<Separator class="mt-4" />
</template>
</ProfileSheetContent>
</SheetContent>
</Sheet>
</template>

View file

@ -23,6 +23,7 @@ const messages: LocaleMessages = {
profileDescription: 'Your Nostr identity and display name.',
profileLoggedOutDescription: 'Sign in or change your preferences.',
login: 'Log in',
menu: 'Menu',
backToHub: 'Back to hub',
hub: 'Hub',
theme: 'Theme',
@ -69,6 +70,8 @@ const messages: LocaleMessages = {
hosting: 'Hosting',
pastEvents: 'Past events',
past: 'Past',
filters: 'Filters',
clearAll: 'Clear all',
},
categories: {
concert: 'Concert',
@ -128,7 +131,7 @@ const messages: LocaleMessages = {
registered: 'Registered',
},
nav: {
feed: 'Feed',
feed: 'Home',
calendar: 'Calendar',
map: 'Map',
favorites: 'Favorites',

View file

@ -23,6 +23,7 @@ const messages: LocaleMessages = {
profileDescription: 'Tu identidad Nostr y nombre de visualización.',
profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.',
login: 'Iniciar sesión',
menu: 'Menú',
backToHub: 'Volver al hub',
hub: 'Hub',
theme: 'Tema',
@ -69,6 +70,8 @@ const messages: LocaleMessages = {
hosting: 'Organizo',
pastEvents: 'Eventos pasados',
past: 'Pasado',
filters: 'Filtros',
clearAll: 'Limpiar todo',
},
categories: {
concert: 'Concierto',

View file

@ -23,6 +23,7 @@ const messages: LocaleMessages = {
profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.',
profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.',
login: 'Se connecter',
menu: 'Menu',
backToHub: 'Retour au hub',
hub: 'Hub',
theme: 'Thème',
@ -69,6 +70,8 @@ const messages: LocaleMessages = {
hosting: 'J\'organise',
pastEvents: 'Événements passés',
past: 'Passé',
filters: 'Filtres',
clearAll: 'Tout effacer',
},
categories: {
concert: 'Concert',
@ -128,7 +131,7 @@ const messages: LocaleMessages = {
registered: 'Enregistré',
},
nav: {
feed: 'Fil',
feed: 'Accueil',
calendar: 'Calendrier',
map: 'Carte',
favorites: 'Favoris',

View file

@ -22,6 +22,7 @@ export interface LocaleMessages {
profileDescription: string
profileLoggedOutDescription: string
login: string
menu: string
backToHub: string
hub: string
theme: string
@ -70,6 +71,8 @@ export interface LocaleMessages {
hosting: string
pastEvents: string
past: string
filters: string
clearAll: string
}
categories: Record<string, string>
detail: {

View file

@ -12,6 +12,10 @@ import type { Activity } from '../types/activity'
const props = defineProps<{
activity: Activity
/** Render a compact row: no hero image, no summary, single-line
* title, tighter padding. Used by the Hosting view where the
* host already knows what their events look like. */
compact?: boolean
}>()
const emit = defineEmits<{
@ -52,42 +56,58 @@ const priceDisplay = computed(() => {
return `${info.price} ${info.currency}`
})
const placeholderBg = computed(() => {
// Generate a consistent hue from the activity title
const hash = props.activity.title.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
const hue = hash % 360
return `hsl(${hue}, 40%, 85%)`
})
const isPast = computed(() => {
const a = props.activity
const end = a.endDate ?? a.startDate
if (!end || isNaN(end.getTime())) return false
return end.getTime() < Date.now()
})
// Pending / rejected events get a washed-out look so the user
// sees at a glance the event isn't live, not just the small badge.
const isNonApproved = computed(
() => !!props.activity.lnbitsStatus && props.activity.lnbitsStatus !== 'approved',
)
</script>
<template>
<Card
class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col"
class="relative cursor-pointer hover:shadow-lg transition-shadow duration-200"
@click="emit('click', activity)"
>
<!-- Image / Placeholder -->
<div class="relative aspect-[16/9] overflow-hidden">
<!-- Wash-out wrapper. The pending/rejected status badge below sits
OUTSIDE this wrapper so it stays in full color and reads
clearly even when the card is dimmed + desaturated. -->
<div
class="transition-opacity duration-200"
:class="[
compact ? 'flex flex-row' : 'flex flex-col',
isNonApproved ? 'opacity-50 grayscale hover:opacity-90' : '',
]"
>
<!-- Compact thumbnail small square preview on the left of the
row when the event carries an image. `self-center` keeps it
vertically centered against a taller content column so we
don't get a top-anchored thumb with dead space below. -->
<img
v-if="compact && activity.image"
:src="activity.image"
:alt="activity.title"
class="w-20 h-20 object-cover shrink-0 self-center ml-3 rounded-md"
loading="lazy"
/>
<!-- Image with overlaid badges. Cards without an image (or in
compact mode) skip the hero area entirely and surface their
badges inline at the top of the content block the solid-
color placeholder + calendar glyph wasn't communicating
anything the title + details don't already. -->
<div v-if="activity.image && !compact" class="relative aspect-[16/9] overflow-hidden rounded-t-lg">
<img
v-if="activity.image"
:src="activity.image"
:alt="activity.title"
class="w-full h-full object-cover"
loading="lazy"
/>
<div
v-else
class="w-full h-full flex items-center justify-center"
:style="{ backgroundColor: placeholderBg }"
>
<Calendar class="w-12 h-12 text-foreground/20" />
</div>
<!-- Category badge -->
<Badge
@ -117,27 +137,13 @@ const isPast = computed(() => {
{{ priceDisplay }}
</Badge>
<!-- Pending/rejected overlay for the creator's own non-approved
drafts. Only present when the activity originated from a
local LNbits event (Nostr-sourced activities have no
lnbitsStatus). -->
<!-- Past badge shown when the activity has already ended. The
pending/rejected status badge that used to share this slot
is now an absolute overlay on Card root, above the wash-out,
so we still suppress Past when isNonApproved (the status
badge is more actionable in that case). -->
<Badge
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
class="absolute bottom-2 left-2 text-xs capitalize"
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
<!-- Past badge shown when the activity has already ended.
Only relevant on the feed when the "Past events" filter
chip is toggled on (otherwise these cards aren't rendered);
on the detail page the card view isn't used. Suppressed
when a pending/rejected status badge is taking the same
slot that case is the creator's own past draft, which is
vanishingly rare and the status hint is more actionable. -->
<Badge
v-if="isPast && !(activity.lnbitsStatus && activity.lnbitsStatus !== 'approved')"
v-if="isPast && !isNonApproved"
variant="outline"
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
>
@ -146,27 +152,62 @@ const isPast = computed(() => {
</Badge>
</div>
<CardContent class="p-4 flex-1 flex flex-col gap-2">
<!-- Title + Bookmark -->
<CardContent
:class="compact ? 'p-3 flex-1 flex flex-col gap-1.5' : 'p-4 flex-1 flex flex-col gap-2'"
>
<!-- Inline badge row (no-image variant + compact variant). Same
badges as the image-overlay set, stacked horizontally at the
top of the content area. The "Yours" chip is dropped in
compact mode since every card in the hosting view is owned. -->
<div v-if="!activity.image || compact" class="flex flex-wrap items-center gap-1.5">
<Badge v-if="categoryLabel" variant="secondary" class="text-xs">
{{ categoryLabel }}
</Badge>
<Badge v-if="priceDisplay" class="text-xs">
{{ priceDisplay }}
</Badge>
<Badge v-if="activity.isMine && !compact" variant="outline" class="text-xs gap-1">
<User class="w-3 h-3" />
Yours
</Badge>
<Badge
v-if="isPast && !isNonApproved"
variant="outline"
class="text-xs gap-1"
>
<History class="w-3 h-3" />
{{ t('activities.filters.past', 'Past') }}
</Badge>
</div>
<!-- Title + Bookmark. Compact mode hides the bookmark (host's
own event bookmarking it would be noise) and clamps the
title to a single line. -->
<div class="flex items-start gap-1">
<h3 class="font-semibold text-foreground line-clamp-2 leading-tight flex-1">
<h3
:class="[
'font-semibold text-foreground leading-tight flex-1',
compact ? 'text-sm line-clamp-1' : 'line-clamp-2',
]"
>
{{ activity.title }}
</h3>
<BookmarkButton
v-if="!compact"
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
/>
</div>
<!-- Summary -->
<!-- Summary (hidden in compact mode) -->
<p
v-if="activity.summary"
v-if="activity.summary && !compact"
class="text-sm text-muted-foreground line-clamp-2"
>
{{ activity.summary }}
</p>
<div class="mt-auto space-y-1.5 pt-2">
<div :class="compact ? 'space-y-1 text-xs' : 'mt-auto space-y-1.5 pt-2'">
<!-- Date/Time -->
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar class="w-3.5 h-3.5 shrink-0" />
@ -216,5 +257,22 @@ const isPast = computed(() => {
</div>
</div>
</CardContent>
</div>
<!-- Status badge absolutely positioned on Card root so it sits
ABOVE the wash-out wrapper and keeps its full color.
Pending + rejected both lean on the destructive token so the
non-approved state reads as "needs attention" in every theme;
the label text differentiates the two specific states.
Bottom-right with a slight downward spill so it anchors
visually without competing with the category chip in the
badge row (full cards) or the thumbnail (compact cards). -->
<Badge
v-if="isNonApproved"
variant="destructive"
class="absolute -bottom-1 right-2 z-10 text-xs capitalize shadow"
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
</Card>
</template>

View file

@ -7,6 +7,10 @@ import type { Activity } from '../types/activity'
defineProps<{
activities: Activity[]
isLoading?: boolean
/** Render compact rows instead of full-image cards. Used by the
* Hosting view so an operator can scan their roster of events
* without the visual weight of hero images they already recognize. */
compact?: boolean
}>()
const emit = defineEmits<{
@ -39,20 +43,24 @@ const { t } = useI18n()
class="flex flex-col items-center justify-center py-16 text-center"
>
<CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
<h3 class="text-lg font-medium text-foreground mb-1">
<h3 class="text-lg font-medium text-foreground">
{{ t('activities.noActivities') }}
</h3>
<p class="text-sm text-muted-foreground">
{{ t('activities.search.noResults') }}
</p>
</div>
<!-- Activity grid -->
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Activity grid compact mode collapses to a single column of
tight rows; default mode is the responsive card grid. The
compact gap is bumped a notch so the status badge spilling
past the card's bottom edge has room to sit between cards. -->
<div
v-else
:class="compact ? 'flex flex-col gap-4' : 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'"
>
<ActivityCard
v-for="activity in activities"
:key="activity.nostrEventId"
:activity="activity"
:compact="compact"
@click="emit('select', activity)"
/>
</div>

View file

@ -443,7 +443,7 @@ onUnmounted(() => {
</template>
<template v-else>
<Zap class="w-4 h-4 mr-2" />
{{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }}
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
</template>
</Button>
</div>

View file

@ -8,35 +8,22 @@ import type { ActivityCategory } from '../types/category'
import type { TemporalFilter, ActivityFilters } from '../types/filters'
import { DEFAULT_FILTERS } from '../types/filters'
// Filter state is hoisted to module scope so every `useActivities()` /
// `useActivityFilters()` call shares the same refs. The bottom-nav
// Hosting tab in activities-app/App.vue and the feed view in
// ActivitiesPage.vue both rely on this — without a shared instance,
// tapping Hosting toggled a private ref the page never saw.
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([])
const selectedDate = ref<Date | undefined>(undefined)
const onlyOwnedTickets = ref(false)
const onlyHosting = ref(false)
const showPast = ref(false)
/**
* Composable for managing activity filter state and applying filters reactively.
*/
export function useActivityFilters() {
const temporal = ref<TemporalFilter>(DEFAULT_FILTERS.temporal)
const selectedCategories = ref<ActivityCategory[]>([])
const selectedDate = ref<Date | undefined>(undefined)
/**
* When true, the feed is narrowed to activities the current user
* holds at least one paid ticket for. Crossed with the
* `ownedActivityIds` set from useOwnedTickets in useActivities
* (this composable stays free of ticket fetching).
*/
const onlyOwnedTickets = ref(false)
/**
* When true, the feed is narrowed to activities the current user
* is hosting (organizer pubkey matches the signed-in user, or the
* row is a local LNbits draft of theirs). Reads `activity.isMine`
* which `useActivities.tagOwnership()` populates.
*/
const onlyHosting = ref(false)
/**
* When false (default), activities that have already ended are
* hidden from the feed. Toggling on includes them so the user can
* browse past events. The date-picker overrides this picking a
* specific past date shows that day's activities regardless,
* mirroring how it overrides the temporal pills.
*/
const showPast = ref(false)
const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value,

View file

@ -188,6 +188,38 @@ export function useTicketScanner(activityId: Ref<string>) {
isPaused.value = false
}
/**
* Mark a ticket as registered without going through the camera
* used when the host knows the attendee in person or accepts an
* alternate proof of identity. Same backend endpoint as a scan
* (so it also gates on event ownership and rejects unpaid /
* already-registered tickets), but skips the scanner pause +
* full-screen banner since the operator initiated the action
* from the roster directly. Refreshes stats on success.
*/
async function registerManually(
ticketId: string,
): Promise<{ ok: boolean; error?: string }> {
const adminKey = currentUser.value?.wallets?.[0]?.adminkey
if (!adminKey) return { ok: false, error: 'No wallet admin key available' }
try {
await ticketApi.registerTicket(ticketId, adminKey)
// Mirror the session-local dedup the scan path uses so a
// subsequent QR scan of the same ticket reports "Already
// scanned" instead of round-tripping a duplicate register.
if (!scanned.value.some(r => r.ticketId === ticketId)) {
scanned.value = [
{ ticketId, name: null, registeredAt: new Date().toISOString() },
...scanned.value,
]
}
await refreshStats()
return { ok: true }
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}
function clearScanned() {
scanned.value = []
lastScan.value = null
@ -210,5 +242,6 @@ export function useTicketScanner(activityId: Ref<string>) {
onDecode,
resume,
clearScanned,
registerManually,
}
}

View file

@ -1,12 +1,31 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Ticket } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { useActivities } from '../composables/useActivities'
import { useOwnedTickets } from '../composables/useOwnedTickets'
import { useAuth } from '@/composables/useAuthService'
import ActivityCalendarView from '../components/ActivityCalendarView.vue'
import type { Activity } from '../types/activity'
const router = useRouter()
const { t } = useI18n()
const { allActivities, subscribe } = useActivities()
const { ownedActivityIds } = useOwnedTickets()
const { isAuthenticated } = useAuth()
// Per-page toggle, intentionally not wired to the feed's
// onlyOwnedTickets filter narrowing the calendar shouldn't also
// narrow the feed the user navigates back to.
const onlyMine = ref(false)
const visibleActivities = computed<Activity[]>(() => {
if (!onlyMine.value) return allActivities.value
const owned = ownedActivityIds.value
return allActivities.value.filter(a => owned.has(a.id))
})
onMounted(() => {
subscribe()
@ -19,8 +38,23 @@ function handleSelectActivity(activity: Activity) {
<template>
<div class="container mx-auto px-4 py-6 max-w-lg">
<!-- Filter chip: narrows the calendar to events the user has
paid tickets for. Hidden when logged out nothing to own.
Left-aligned so it doesn't collide with the fixed top-right
hamburger menu. -->
<div v-if="isAuthenticated" class="mb-3 flex">
<Button
:variant="onlyMine ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="onlyMine = !onlyMine"
>
<Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }}
</Button>
</div>
<ActivityCalendarView
:activities="allActivities"
:activities="visibleActivities"
@select-activity="handleSelectActivity"
/>
</div>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Button } from '@/components/ui/button'
@ -8,9 +8,10 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next'
import { Separator } from '@/components/ui/separator'
import { SlidersHorizontal, History, CalendarDays, Plus } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities'
import { useAuth } from '@/composables/useAuthService'
import { useActivitiesStore } from '../stores/activities'
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
@ -20,6 +21,7 @@ import type { Activity } from '../types/activity'
const router = useRouter()
const { t } = useI18n()
const activitiesStore = useActivitiesStore()
const {
activities,
@ -29,24 +31,26 @@ const {
selectedCategories,
hasActiveFilters,
selectedDate,
onlyOwnedTickets,
onlyHosting,
showPast,
onlyHosting,
selectDate,
setTemporal,
toggleCategory,
clearCategories,
toggleOwnedTickets,
toggleHosting,
togglePast,
resetFilters,
subscribe,
} = useActivities()
const { isAuthenticated } = useAuth()
const filtersOpen = ref(false)
// Badge count on the Filters trigger so the user can see at a glance
// that hidden toggles (past-events, categories) are currently active
// even when the collapsible is closed.
const filterCount = computed(
() => selectedCategories.value.length + (showPast.value ? 1 : 0),
)
onMounted(() => {
subscribe()
})
@ -54,85 +58,110 @@ onMounted(() => {
function handleSelectActivity(activity: Activity) {
router.push({ name: 'activity-detail', params: { id: activity.id } })
}
// Create-activity CTA in the Hosting view. Calendar-tab page lives
// on /activities/calendar; the icon button at the end of the date
// strip is the only entry point now that the bottom-nav Calendar
// tab is gone.
function openCreate() {
activitiesStore.editingEvent = null
activitiesStore.showCreateDialog = true
}
function openCalendar() {
router.push('/activities/calendar')
}
</script>
<template>
<div class="container mx-auto py-6 px-4">
<div class="container mx-auto py-4 px-4">
<!-- Page header -->
<div class="mb-4">
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ t('activities.title') }}
</h1>
</div>
<h1 class="mb-3 text-xl sm:text-2xl font-bold text-foreground">
{{ t('activities.title') }}
</h1>
<!-- Search with dropdown overlay -->
<div class="mb-4">
<div class="mb-3">
<ActivitySearchOverlay
:activities="activities"
@select="handleSelectActivity"
/>
</div>
<!-- Date picker strip (p'a semana style) -->
<div class="mb-4">
<DatePickerStrip :selected-date="selectedDate" @select="selectDate" />
</div>
<!-- Temporal filter pills -->
<div class="mb-4">
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
</div>
<!-- Role + past-events filter chips. The role chips ("My tickets",
"Hosting") narrow the feed to activities the signed-in user
has skin in and are hidden when logged out. The "Past events"
chip is always visible since past-browsing doesn't require an
account. -->
<div class="mb-4 flex flex-wrap gap-2">
<template v-if="isAuthenticated">
<Button
:variant="onlyOwnedTickets ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleOwnedTickets"
>
<Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }}
</Button>
<Button
:variant="onlyHosting ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleHosting"
>
<Megaphone class="w-3.5 h-3.5" />
{{ t('activities.filters.hosting', 'Hosting') }}
</Button>
</template>
<!-- Date picker strip + calendar shortcut. The calendar icon used
to be a bottom-nav tab; it now lives on the right of the week
strip so the tabs row stays focused on the primary views.
Hidden in the Hosting view operators don't need calendar
navigation when they're managing their own roster. -->
<div v-if="!onlyHosting" class="mb-3 flex items-center gap-2">
<DatePickerStrip
class="flex-1 min-w-0"
:selected-date="selectedDate"
@select="selectDate"
/>
<Button
:variant="showPast ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="togglePast"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0"
:aria-label="t('activities.nav.calendar')"
@click="openCalendar"
>
<History class="w-3.5 h-3.5" />
{{ t('activities.filters.pastEvents', 'Past events') }}
<CalendarDays class="h-4 w-4" />
</Button>
</div>
<!-- Category filters (collapsible) -->
<Collapsible v-model:open="filtersOpen" class="mb-6">
<CollapsibleTrigger as-child>
<Button variant="ghost" size="sm" class="gap-1.5 text-muted-foreground">
<SlidersHorizontal class="w-4 h-4" />
Categories
<span v-if="selectedCategories.length > 0" class="text-xs bg-primary text-primary-foreground rounded-full px-1.5">
{{ selectedCategories.length }}
</span>
<ChevronDown class="w-3.5 h-3.5 transition-transform" :class="{ 'rotate-180': filtersOpen }" />
<!-- Filters trigger + Clear-all stay stationary in a left-aligned
column; only the temporal pills scroll horizontally. The
Filters icon (with a count badge when past-events or any
categories are active) opens a collapsible that hosts the
past-events toggle + category chips below. Hidden in the
Hosting view the operator's roster doesn't need them. -->
<Collapsible v-if="!onlyHosting" v-model:open="filtersOpen" class="mb-3">
<div class="flex items-start gap-3">
<div class="shrink-0 flex flex-col items-center gap-0.5">
<CollapsibleTrigger as-child>
<Button
variant="ghost"
size="icon"
class="rounded-full h-8 w-8 relative"
:class="{ 'bg-accent text-accent-foreground': filtersOpen || filterCount > 0 }"
:aria-label="t('activities.filters.filters', 'Filters')"
:aria-expanded="filtersOpen"
>
<SlidersHorizontal class="w-4 h-4" />
<span
v-if="filterCount > 0"
class="absolute -top-1 -right-1 min-w-[16px] h-[16px] px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-semibold flex items-center justify-center"
>
{{ filterCount }}
</span>
</Button>
</CollapsibleTrigger>
<Button
v-if="hasActiveFilters"
variant="ghost"
size="sm"
class="h-5 px-1 text-[10px] text-muted-foreground"
@click="resetFilters"
>
{{ t('activities.filters.clearAll', 'Clear all') }}
</Button>
</div>
<div class="flex-1 min-w-0 pt-0.5">
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
</div>
</div>
<CollapsibleContent class="mt-3 space-y-3">
<Button
:variant="showPast ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="togglePast"
>
<History class="w-3.5 h-3.5" />
{{ t('activities.filters.pastEvents', 'Past events') }}
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="mt-2">
<Separator />
<CategoryFilterBar
:selected="selectedCategories"
@toggle="toggleCategory"
@ -141,23 +170,31 @@ function handleSelectActivity(activity: Activity) {
</CollapsibleContent>
</Collapsible>
<!-- Active filters indicator -->
<div v-if="hasActiveFilters" class="flex items-center gap-2 mb-4">
<span class="text-xs text-muted-foreground">Filters active</span>
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="resetFilters">
Clear all
</Button>
</div>
<!-- Create-activity CTA shown when the Hosting bottom-nav tab is
active. Replaces the dedicated Create entry that used to live
in the bottom nav; lives here so it shows up exactly when the
user is in the "events I'm running" view. -->
<Button
v-if="onlyHosting"
class="w-full mb-3 gap-1.5"
@click="openCreate"
>
<Plus class="w-4 h-4" />
{{ t('activities.createNew') }}
</Button>
<!-- Error state -->
<div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm">
{{ error }}
</div>
<!-- Activity feed -->
<!-- Activity feed. The Hosting view renders compact rows so the
operator can scan their roster without the visual weight of
hero images they already recognize. -->
<ActivityList
:activities="activities"
:is-loading="isLoading"
:compact="onlyHosting"
@select="handleSelectActivity"
/>
</div>

View file

@ -170,41 +170,14 @@ function goToMyTickets() {
<template>
<div class="container mx-auto py-6 px-4 max-w-3xl">
<!-- Top bar -->
<div class="flex items-center justify-between mb-4">
<!-- Top bar back-link only. Edit moves into the title row as a
prominent icon button; Scan moves into the tickets section
where it replaces the Buy-ticket CTA for the host. -->
<div class="flex items-center mb-4">
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
<ArrowLeft class="w-4 h-4" />
Back
</Button>
<div class="flex items-center gap-1.5">
<Button
v-if="ownedLnbitsEvent"
variant="ghost"
size="sm"
class="gap-1.5"
@click="openScannerPage"
aria-label="Scan tickets"
>
<ScanLine class="w-4 h-4" />
Scan
</Button>
<Button
v-if="ownedLnbitsEvent"
variant="ghost"
size="sm"
class="gap-1.5"
@click="openEditDialog"
aria-label="Edit event"
>
<Pencil class="w-4 h-4" />
Edit
</Button>
<BookmarkButton
v-if="activity"
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
/>
</div>
</div>
<!-- Loading -->
@ -233,104 +206,128 @@ function goToMyTickets() {
/>
</div>
<!-- Title + Category -->
<!-- Title + bookmark + captions -->
<div>
<div class="flex items-start gap-2 mb-2">
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
<div class="flex flex-wrap items-start gap-2 mb-2">
<!-- "Yours" leads the row in the highlighted variant so the
ownership signal stands out against the neutral
category/tag chips that follow. -->
<Badge
v-if="activity.isMine"
variant="secondary"
class="shrink-0"
>
Yours
</Badge>
<Badge v-if="categoryLabel" variant="outline" class="shrink-0">
{{ categoryLabel }}
</Badge>
<Badge
v-if="activity.lnbitsStatus && activity.lnbitsStatus !== 'approved'"
:variant="activity.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
class="shrink-0 mt-1 capitalize"
class="shrink-0 capitalize"
>
{{ activity.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
</Badge>
<Badge
v-if="activity.isMine"
variant="outline"
class="shrink-0 mt-1"
>
Yours
</Badge>
<div v-for="tag in activity.tags.slice(1)" :key="tag">
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
</div>
</div>
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ activity.title }}
</h1>
<div class="flex items-start justify-between gap-3">
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
{{ activity.title }}
</h1>
<div class="flex items-center gap-1 shrink-0 mt-1">
<Button
v-if="ownedLnbitsEvent"
variant="default"
size="icon"
class="h-8 w-8"
:aria-label="t('activities.detail.editEvent', 'Edit event')"
@click="openEditDialog"
>
<Pencil class="w-4 h-4" />
</Button>
<!-- Hosts don't need to favorite their own event the
"Yours" badge already marks it, and the bookmark
affordance is meant for discovery, not management. -->
<BookmarkButton
v-else
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
/>
</div>
</div>
<p v-if="activity.summary" class="text-muted-foreground mt-2">
{{ activity.summary }}
</p>
<!-- When + Where captions -->
<div class="mt-3 space-y-1 text-sm text-muted-foreground">
<div class="flex items-start gap-1.5">
<Calendar class="w-4 h-4 shrink-0 mt-0.5" />
<span>
{{ dateDisplay }}
<span v-if="activity.timezone" class="opacity-70">({{ activity.timezone }})</span>
</span>
</div>
<div v-if="activity.location" class="flex items-start gap-1.5">
<MapPin class="w-4 h-4 shrink-0 mt-0.5" />
<span>{{ activity.location }}</span>
</div>
</div>
</div>
<Separator />
<!-- Info section -->
<div class="grid gap-4 sm:grid-cols-2">
<!-- When -->
<div class="bg-muted/50 rounded-lg p-4 space-y-1">
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<Calendar class="w-4 h-4" />
{{ t('activities.detail.when') }}
</div>
<p class="text-sm text-muted-foreground">{{ dateDisplay }}</p>
<p v-if="activity.timezone" class="text-xs text-muted-foreground/70">
{{ activity.timezone }}
</p>
</div>
<!-- Where -->
<div v-if="activity.location" class="bg-muted/50 rounded-lg p-4 space-y-1">
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<MapPin class="w-4 h-4" />
{{ t('activities.detail.location') }}
</div>
<p class="text-sm text-muted-foreground">{{ activity.location }}</p>
</div>
<!-- Description -->
<div class="prose prose-sm max-w-none text-foreground">
<p class="whitespace-pre-wrap">{{ activity.description }}</p>
</div>
<!-- RSVP -->
<!-- RSVP hidden for the host since RSVPing to your own event
is a noise affordance. -->
<!-- The NIP-52 RSVP `a` tag must reference the activity's actual kind
(31922 for date-based, 31923 for time-based). Without this prop the
button would default to time-based for every activity, leaving RSVPs
on date-based activities pointing at a non-existent event coord. -->
<RSVPButton
v-if="!ownedLnbitsEvent"
:pubkey="activity.organizer.pubkey"
:d-tag="activity.id"
:kind="activity.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
/>
<!-- Host's primary CTA is to scan tickets at the door. Lives
OUTSIDE the ticketInfo gate so it appears even when the
event was published without AIO ticket tags a host always
gets to scan attempts. Stays available for past events too
so the host can still verify attendance after the fact. -->
<Button
v-if="ownedLnbitsEvent"
class="w-full gap-1.5"
size="lg"
@click="openScannerPage"
>
<ScanLine class="w-4 h-4" />
{{ t('activities.detail.scanTickets', 'Scan tickets') }}
</Button>
<!-- Tickets gated on the activity carrying ticketInfo (set
by the calendarActivity converter from the AIO custom
tickets_* tags on the published event). Sections render
bottom-up: availability count, then existing owned
tickets (when count > 0) above a Purchase CTA (when
capacity remains). -->
<div v-if="activity.ticketInfo" class="space-y-3">
<div class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
<Ticket class="w-4 h-4 shrink-0" />
<span v-if="activity.ticketInfo.available === undefined">
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else-if="activity.ticketInfo.available > 0">
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
</span>
<span v-else class="text-destructive font-medium">
{{ t('activities.detail.soldOut') }}
</span>
</div>
tickets_* tags on the published event). Skipped for the
host entirely they have the Scan CTA above and don't
need a Buy CTA for their own event. -->
<div v-if="activity.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
<div
v-if="ownedPaidCount > 0"
class="bg-primary/10 border border-primary/30 rounded-lg p-4 flex items-center justify-between gap-3"
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
>
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
{{ t('activities.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
</div>
<Button variant="outline" size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
<Button size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
<Ticket class="w-4 h-4" />
{{ t('activities.detail.viewMyTickets', 'View in My Tickets') }}
</Button>
@ -343,10 +340,11 @@ function goToMyTickets() {
<History class="w-4 h-4 shrink-0" />
{{ t('activities.detail.pastEvent', 'This event has already happened') }}
</div>
<div v-else-if="canBuyTicket">
<div v-else-if="canBuyTicket" class="space-y-1">
<Button
class="w-full gap-1.5"
size="lg"
:variant="ownedPaidCount > 0 ? 'outline' : 'default'"
@click="openPurchaseDialog"
>
<Ticket class="w-4 h-4" />
@ -359,6 +357,14 @@ function goToMyTickets() {
: `${activity.ticketInfo.price} ${activity.ticketInfo.currency}` }}
</span>
</Button>
<p class="text-xs text-muted-foreground text-center">
<span v-if="activity.ticketInfo.available === undefined">
{{ t('activities.detail.unlimitedTickets', 'Unlimited tickets') }}
</span>
<span v-else>
{{ t('activities.detail.ticketsAvailable', { count: activity.ticketInfo.available }) }}
</span>
</p>
</div>
<p
v-else-if="ownedPaidCount === 0"
@ -375,6 +381,13 @@ function goToMyTickets() {
@update:is-open="showPurchaseDialog = $event"
/>
<!-- External references -->
<div v-if="activity.tags.length > 0" class="space-y-2">
<!-- References would go here in future phases -->
</div>
<Separator />
<!-- Organizer -->
<div class="bg-muted/50 rounded-lg p-4">
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
@ -382,18 +395,6 @@ function goToMyTickets() {
</p>
<OrganizerCard :pubkey="activity.organizer.pubkey" />
</div>
<Separator />
<!-- Description -->
<div class="prose prose-sm max-w-none text-foreground">
<p class="whitespace-pre-wrap">{{ activity.description }}</p>
</div>
<!-- External references -->
<div v-if="activity.tags.length > 0" class="space-y-2">
<!-- References would go here in future phases -->
</div>
</div>
</div>
</template>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import {
ArrowLeft,
CheckCircle2,
@ -9,15 +10,20 @@ import {
Ticket,
ScanLine,
RefreshCw,
UserCheck,
Search,
} from 'lucide-vue-next'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import QRScanner from '@/components/ui/qr-scanner.vue'
import { useTicketScanner } from '../composables/useTicketScanner'
import type { EventTicket } from '../composables/useTicketScanner'
import { useActivityDetail } from '../composables/useActivityDetail'
import { useFuzzySearch } from '@/composables/useFuzzySearch'
const route = useRoute()
const router = useRouter()
@ -35,8 +41,14 @@ const {
refreshStats,
onDecode,
resume,
registerManually,
} = useTicketScanner(activityId)
// Tracks tickets currently mid-register (manual button click), so each
// row can render a per-row spinner without blocking the rest of the
// list. A Set keeps add/remove O(1).
const pendingRegister = ref<Set<string>>(new Set())
const scannerOpen = ref(true)
const activeTab = ref<'scanner' | 'list'>('scanner')
@ -64,11 +76,55 @@ const remainingCount = computed(() => {
return Math.max(0, soldCount.value - registeredCount.value)
})
// Registered tickets only what the "Scanned" tab shows.
const registeredTickets = computed(
() => eventStats.value?.tickets.filter(t => t.registered) ?? [],
// Full ticket roster, sorted so unregistered (actionable) rows lead
// and registered rows follow most-recent-first. Powers the Tickets
// tab where the host can manually register attendees who can prove
// identity but can't present a scannable QR.
const allTickets = computed<EventTicket[]>(() => {
const list = eventStats.value?.tickets ?? []
return [...list].sort((a, b) => {
if (a.registered !== b.registered) return a.registered ? 1 : -1
if (a.registered && b.registered) {
return (b.registeredAt ?? '').localeCompare(a.registeredAt ?? '')
}
return 0
})
})
const totalTicketsCount = computed(() => eventStats.value?.tickets.length ?? 0)
const unregisteredCount = computed(
() => allTickets.value.filter(t => !t.registered).length,
)
// Fuzzy match on holder name + ticket id. When the search box is
// empty, Fuse returns the list in its incoming order so our
// unregistered-first sort is preserved.
const { searchQuery, filteredItems: searchedTickets } = useFuzzySearch(
allTickets,
{
fuseOptions: {
keys: [
{ name: 'name', weight: 0.7 },
{ name: 'id', weight: 0.3 },
],
threshold: 0.3,
ignoreLocation: true,
},
matchAllWhenSearchEmpty: true,
},
)
async function handleManualRegister(ticket: EventTicket) {
pendingRegister.value.add(ticket.id)
const res = await registerManually(ticket.id)
pendingRegister.value.delete(ticket.id)
if (res.ok) {
toast.success(`Registered ${ticket.name || ticket.id.slice(0, 8) + '…'}`)
} else {
toast.error(res.error || 'Failed to register')
}
}
function handleResult(qrText: string) {
// Don't pause the scanner useQRScanner's `maxScansPerSecond: 5`
// already throttles, and useTicketScanner.onDecode dedups the same
@ -156,13 +212,21 @@ function fmtTime(iso: string) {
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="scanner" class="gap-1.5">
<ScanLine class="w-4 h-4" />
Scanner
<!-- Icon + label wrapped in a real flex container so they
share a gap and items-center alignment. TabsTrigger's
internal slot lives in an inline span, so a `gap-1.5`
on the trigger itself never reaches these two children. -->
<TabsTrigger value="scanner">
<span class="inline-flex items-center justify-center gap-1.5">
<ScanLine class="w-4 h-4" />
Scanner
</span>
</TabsTrigger>
<TabsTrigger value="list" class="gap-1.5">
<Ticket class="w-4 h-4" />
Scanned ({{ registeredCount }})
<TabsTrigger value="list">
<span class="inline-flex items-center justify-center gap-1.5">
<Ticket class="w-4 h-4" />
Tickets ({{ totalTicketsCount }})
</span>
</TabsTrigger>
</TabsList>
@ -190,39 +254,83 @@ function fmtTime(iso: string) {
<TabsContent value="list" class="mt-0">
<div class="space-y-3">
<h2 class="text-sm font-medium text-foreground">
{{ registeredCount }} ticket{{ registeredCount === 1 ? '' : 's' }} registered
{{ registeredCount }} / {{ totalTicketsCount }} registered
<span v-if="unregisteredCount > 0" class="text-muted-foreground font-normal">
· {{ unregisteredCount }} to go
</span>
</h2>
<ScrollArea v-if="registeredTickets.length > 0" class="h-[60vh]">
<!-- Fuzzy filter on holder name + ticket id (Fuse.js via
useFuzzySearch). Empty query all rows in their
sort order; typing reordered by relevance. -->
<div v-if="allTickets.length > 0" class="relative">
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
v-model="searchQuery"
type="search"
placeholder="Search by name or ticket id…"
class="pl-8 h-9"
/>
</div>
<!-- Unregistered rows lead the list so the operator can act
on the actionable ones first; tap "Register" to mark an
attendee present without a QR (e.g. lost phone, known
in person). Failures surface as a toast; the row reverts. -->
<ScrollArea v-if="searchedTickets.length > 0" class="h-[60vh]">
<ul class="space-y-1.5 pr-3">
<li
v-for="record in registeredTickets"
:key="record.id"
v-for="ticket in searchedTickets"
:key="ticket.id"
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
>
<div class="min-w-0">
<div class="flex items-center gap-2">
<Badge
v-if="record.registeredAt"
v-if="ticket.registered && ticket.registeredAt"
variant="secondary"
class="text-[10px] font-mono px-1.5"
>
{{ fmtTime(record.registeredAt) }}
{{ fmtTime(ticket.registeredAt) }}
</Badge>
<span v-if="record.name" class="font-medium text-foreground">
{{ record.name }}
<span v-if="ticket.name" class="font-medium text-foreground">
{{ ticket.name }}
</span>
</div>
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
{{ record.id }}
{{ ticket.id }}
</p>
</div>
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
<CheckCircle2
v-if="ticket.registered"
class="w-4 h-4 text-emerald-500 shrink-0"
/>
<Button
v-else
size="sm"
variant="outline"
class="shrink-0 gap-1"
:disabled="pendingRegister.has(ticket.id)"
@click="handleManualRegister(ticket)"
>
<RefreshCw
v-if="pendingRegister.has(ticket.id)"
class="w-3.5 h-3.5 animate-spin"
/>
<UserCheck v-else class="w-3.5 h-3.5" />
Register
</Button>
</li>
</ul>
</ScrollArea>
<p
v-else-if="allTickets.length === 0"
class="text-sm text-muted-foreground text-center py-12"
>
No tickets sold yet.
</p>
<p v-else class="text-sm text-muted-foreground text-center py-12">
No tickets scanned yet.
No tickets match {{ searchQuery }}.
</p>
</div>
</TabsContent>

View file

@ -1,256 +0,0 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ExpensesAPI } from '../../services/ExpensesAPI'
import type { Account } from '../../types'
import { PermissionType } from '../../types'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Loader2 } from 'lucide-vue-next'
interface Props {
isOpen: boolean
accounts: Account[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
permissionGranted: []
}>()
const { user } = useAuth()
const toast = useToast()
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const isGranting = ref(false)
const adminKey = computed(() => user.value?.wallets?.[0]?.adminkey)
// Form schema
const formSchema = toTypedSchema(
z.object({
user_id: z.string().min(1, 'User ID is required'),
account_id: z.string().min(1, 'Account is required'),
permission_type: z.nativeEnum(PermissionType, {
errorMap: () => ({ message: 'Permission type is required' })
}),
notes: z.string().optional(),
expires_at: z.string().optional()
})
)
// Setup form
const form = useForm({
validationSchema: formSchema,
initialValues: {
user_id: '',
account_id: '',
permission_type: PermissionType.READ,
notes: '',
expires_at: ''
}
})
const { resetForm, meta } = form
const isFormValid = computed(() => meta.value.valid)
// Permission type options
const permissionTypes = [
{ value: PermissionType.READ, label: 'Read', description: 'View account and balance' },
{
value: PermissionType.SUBMIT_EXPENSE,
label: 'Submit Expense',
description: 'Submit expenses to this account'
},
{ value: PermissionType.MANAGE, label: 'Manage', description: 'Full account management' }
]
// Submit form
const onSubmit = form.handleSubmit(async (values) => {
if (!adminKey.value) {
toast.error('Admin access required')
return
}
isGranting.value = true
try {
await expensesAPI.grantPermission(adminKey.value, {
user_id: values.user_id,
account_id: values.account_id,
permission_type: values.permission_type,
notes: values.notes || undefined,
expires_at: values.expires_at || undefined
})
emit('permissionGranted')
resetForm()
} catch (error) {
console.error('Failed to grant permission:', error)
toast.error('Failed to grant permission', {
description: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
isGranting.value = false
}
})
// Handle dialog close
function handleClose() {
if (!isGranting.value) {
resetForm()
emit('close')
}
}
</script>
<template>
<Dialog :open="props.isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Grant Account Permission</DialogTitle>
<DialogDescription>
Grant a user permission to access an expense account. Permissions on parent accounts
cascade to children.
</DialogDescription>
</DialogHeader>
<form @submit="onSubmit" class="space-y-4">
<!-- User ID -->
<FormField v-slot="{ componentField }" name="user_id">
<FormItem>
<FormLabel>User ID *</FormLabel>
<FormControl>
<Input
placeholder="Enter user wallet ID"
v-bind="componentField"
:disabled="isGranting"
/>
</FormControl>
<FormDescription>The wallet ID of the user to grant permission to</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Account -->
<FormField v-slot="{ componentField }" name="account_id">
<FormItem>
<FormLabel>Account *</FormLabel>
<Select v-bind="componentField" :disabled="isGranting">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select account" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
v-for="account in props.accounts"
:key="account.id"
:value="account.id"
>
{{ account.name }}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>Account to grant access to</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Permission Type -->
<FormField v-slot="{ componentField }" name="permission_type">
<FormItem>
<FormLabel>Permission Type *</FormLabel>
<Select v-bind="componentField" :disabled="isGranting">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select permission type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem
v-for="type in permissionTypes"
:key="type.value"
:value="type.value"
>
<div>
<div class="font-medium">{{ type.label }}</div>
<div class="text-sm text-muted-foreground">{{ type.description }}</div>
</div>
</SelectItem>
</SelectContent>
</Select>
<FormDescription>Type of permission to grant</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Expiration Date (Optional) -->
<FormField v-slot="{ componentField }" name="expires_at">
<FormItem>
<FormLabel>Expiration Date (Optional)</FormLabel>
<FormControl>
<Input
type="datetime-local"
v-bind="componentField"
:disabled="isGranting"
/>
</FormControl>
<FormDescription>Leave empty for permanent access</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<!-- Notes (Optional) -->
<FormField v-slot="{ componentField }" name="notes">
<FormItem>
<FormLabel>Notes (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Add notes about this permission..."
v-bind="componentField"
:disabled="isGranting"
/>
</FormControl>
<FormDescription>Optional notes for admin reference</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<DialogFooter>
<Button
type="button"
variant="outline"
@click="handleClose"
:disabled="isGranting"
>
Cancel
</Button>
<Button type="submit" :disabled="isGranting || !isFormValid">
<Loader2 v-if="isGranting" class="mr-2 h-4 w-4 animate-spin" />
{{ isGranting ? 'Granting...' : 'Grant Permission' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View file

@ -1,399 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useAuth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ExpensesAPI } from '../../services/ExpensesAPI'
import type { AccountPermission, Account } from '../../types'
import { PermissionType } from '../../types'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Loader2, Plus, Trash2, Users, Shield } from 'lucide-vue-next'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import GrantPermissionDialog from './GrantPermissionDialog.vue'
const { user } = useAuth()
const toast = useToast()
const expensesAPI = injectService<ExpensesAPI>(SERVICE_TOKENS.EXPENSES_API)
const permissions = ref<AccountPermission[]>([])
const accounts = ref<Account[]>([])
const isLoading = ref(false)
const showGrantDialog = ref(false)
const permissionToRevoke = ref<AccountPermission | null>(null)
const showRevokeDialog = ref(false)
const adminKey = computed(() => user.value?.wallets?.[0]?.adminkey)
// Get permission type badge variant
function getPermissionBadge(type: PermissionType) {
switch (type) {
case PermissionType.READ:
return 'default'
case PermissionType.SUBMIT_EXPENSE:
return 'secondary'
case PermissionType.MANAGE:
return 'destructive'
default:
return 'outline'
}
}
// Get permission type label
function getPermissionLabel(type: PermissionType): string {
switch (type) {
case PermissionType.READ:
return 'Read'
case PermissionType.SUBMIT_EXPENSE:
return 'Submit Expense'
case PermissionType.MANAGE:
return 'Manage'
default:
return type
}
}
// Get account name by ID
function getAccountName(accountId: string): string {
const account = accounts.value.find((a) => a.id === accountId)
return account?.name || accountId
}
// Format date
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
}
// Load accounts
async function loadAccounts() {
if (!adminKey.value) return
try {
accounts.value = await expensesAPI.getAccounts(adminKey.value)
} catch (error) {
console.error('Failed to load accounts:', error)
toast.error('Failed to load accounts', {
description: error instanceof Error ? error.message : 'Unknown error'
})
}
}
// Load all permissions
async function loadPermissions() {
if (!adminKey.value) {
toast.error('Admin access required', {
description: 'You need admin privileges to manage permissions'
})
return
}
isLoading.value = true
try {
permissions.value = await expensesAPI.listPermissions(adminKey.value)
} catch (error) {
console.error('Failed to load permissions:', error)
toast.error('Failed to load permissions', {
description: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
isLoading.value = false
}
}
// Handle permission granted
function handlePermissionGranted() {
showGrantDialog.value = false
loadPermissions()
toast.success('Permission granted', {
description: 'User permission has been successfully granted'
})
}
// Confirm revoke permission
function confirmRevoke(permission: AccountPermission) {
permissionToRevoke.value = permission
showRevokeDialog.value = true
}
// Revoke permission
async function revokePermission() {
if (!adminKey.value || !permissionToRevoke.value) return
try {
await expensesAPI.revokePermission(adminKey.value, permissionToRevoke.value.id)
toast.success('Permission revoked', {
description: 'User permission has been successfully revoked'
})
loadPermissions()
} catch (error) {
console.error('Failed to revoke permission:', error)
toast.error('Failed to revoke permission', {
description: error instanceof Error ? error.message : 'Unknown error'
})
} finally {
showRevokeDialog.value = false
permissionToRevoke.value = null
}
}
// Group permissions by user
const permissionsByUser = computed(() => {
const grouped = new Map<string, AccountPermission[]>()
for (const permission of permissions.value) {
const existing = grouped.get(permission.user_id) || []
existing.push(permission)
grouped.set(permission.user_id, existing)
}
return grouped
})
// Group permissions by account
const permissionsByAccount = computed(() => {
const grouped = new Map<string, AccountPermission[]>()
for (const permission of permissions.value) {
const existing = grouped.get(permission.account_id) || []
existing.push(permission)
grouped.set(permission.account_id, existing)
}
return grouped
})
onMounted(() => {
loadAccounts()
loadPermissions()
})
</script>
<template>
<div class="container mx-auto p-6 space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold">Permission Management</h1>
<p class="text-muted-foreground">
Manage user access to expense accounts
</p>
</div>
<Button @click="showGrantDialog = true" :disabled="isLoading">
<Plus class="mr-2 h-4 w-4" />
Grant Permission
</Button>
</div>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Shield class="h-5 w-5" />
Account Permissions
</CardTitle>
<CardDescription>
View and manage all account permissions. Permissions on parent accounts cascade to
children.
</CardDescription>
</CardHeader>
<CardContent>
<Tabs default-value="by-user" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="by-user">
<Users class="mr-2 h-4 w-4" />
By User
</TabsTrigger>
<TabsTrigger value="by-account">
<Shield class="mr-2 h-4 w-4" />
By Account
</TabsTrigger>
</TabsList>
<!-- By User View -->
<TabsContent value="by-user" class="space-y-4">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div v-else-if="permissionsByUser.size === 0" class="text-center py-8">
<p class="text-muted-foreground">No permissions granted yet</p>
</div>
<div v-else class="space-y-4">
<div
v-for="[userId, userPermissions] in permissionsByUser"
:key="userId"
class="border rounded-lg p-4"
>
<h3 class="font-semibold mb-2">User: {{ userId }}</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Account</TableHead>
<TableHead>Permission</TableHead>
<TableHead>Granted</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Notes</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="permission in userPermissions" :key="permission.id">
<TableCell class="font-medium">
{{ getAccountName(permission.account_id) }}
</TableCell>
<TableCell>
<Badge :variant="getPermissionBadge(permission.permission_type)">
{{ getPermissionLabel(permission.permission_type) }}
</Badge>
</TableCell>
<TableCell>{{ formatDate(permission.granted_at) }}</TableCell>
<TableCell>
{{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
</TableCell>
<TableCell>
<span class="text-sm text-muted-foreground">
{{ permission.notes || '-' }}
</span>
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
@click="confirmRevoke(permission)"
:disabled="isLoading"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</TabsContent>
<!-- By Account View -->
<TabsContent value="by-account" class="space-y-4">
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div v-else-if="permissionsByAccount.size === 0" class="text-center py-8">
<p class="text-muted-foreground">No permissions granted yet</p>
</div>
<div v-else class="space-y-4">
<div
v-for="[accountId, accountPermissions] in permissionsByAccount"
:key="accountId"
class="border rounded-lg p-4"
>
<h3 class="font-semibold mb-2">Account: {{ getAccountName(accountId) }}</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Permission</TableHead>
<TableHead>Granted</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Notes</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="permission in accountPermissions" :key="permission.id">
<TableCell class="font-medium">{{ permission.user_id }}</TableCell>
<TableCell>
<Badge :variant="getPermissionBadge(permission.permission_type)">
{{ getPermissionLabel(permission.permission_type) }}
</Badge>
</TableCell>
<TableCell>{{ formatDate(permission.granted_at) }}</TableCell>
<TableCell>
{{ permission.expires_at ? formatDate(permission.expires_at) : 'Never' }}
</TableCell>
<TableCell>
<span class="text-sm text-muted-foreground">
{{ permission.notes || '-' }}
</span>
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
@click="confirmRevoke(permission)"
:disabled="isLoading"
>
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
<!-- Grant Permission Dialog -->
<GrantPermissionDialog
:is-open="showGrantDialog"
:accounts="accounts"
@close="showGrantDialog = false"
@permission-granted="handlePermissionGranted"
/>
<!-- Revoke Confirmation Dialog -->
<AlertDialog :open="showRevokeDialog" @update:open="showRevokeDialog = $event">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke Permission?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to revoke this permission? The user will immediately lose access.
<div v-if="permissionToRevoke" class="mt-4 p-4 bg-muted rounded-md">
<p class="font-medium">Permission Details:</p>
<p class="text-sm mt-2">
<strong>User:</strong> {{ permissionToRevoke.user_id }}
</p>
<p class="text-sm">
<strong>Account:</strong> {{ getAccountName(permissionToRevoke.account_id) }}
</p>
<p class="text-sm">
<strong>Type:</strong> {{ getPermissionLabel(permissionToRevoke.permission_type) }}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction @click="revokePermission" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
Revoke
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</template>

View file

@ -11,8 +11,6 @@ import type {
IncomeEntry,
AccountNode,
UserInfo,
AccountPermission,
GrantPermissionRequest,
TransactionListResponse
} from '../types'
import { appConfig } from '@/app.config'
@ -343,93 +341,6 @@ export class ExpensesAPI extends BaseService {
}
}
/**
* List all account permissions (admin only)
*
* @param adminKey - Admin key for authentication
*/
async listPermissions(adminKey: string): Promise<AccountPermission[]> {
try {
const response = await fetch(`${this.baseUrl}/libra/api/v1/permissions`, {
method: 'GET',
headers: this.getHeaders(adminKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
throw new Error(`Failed to list permissions: ${response.statusText}`)
}
const permissions = await response.json()
return permissions as AccountPermission[]
} catch (error) {
console.error('[ExpensesAPI] Error listing permissions:', error)
throw error
}
}
/**
* Grant account permission to a user (admin only)
*
* @param adminKey - Admin key for authentication
* @param request - Permission grant request
*/
async grantPermission(
adminKey: string,
request: GrantPermissionRequest
): Promise<AccountPermission> {
try {
const response = await fetch(`${this.baseUrl}/libra/api/v1/permissions`, {
method: 'POST',
headers: this.getHeaders(adminKey),
body: JSON.stringify(request),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage =
errorData.detail || `Failed to grant permission: ${response.statusText}`
throw new Error(errorMessage)
}
const permission = await response.json()
return permission as AccountPermission
} catch (error) {
console.error('[ExpensesAPI] Error granting permission:', error)
throw error
}
}
/**
* Revoke account permission (admin only)
*
* @param adminKey - Admin key for authentication
* @param permissionId - ID of the permission to revoke
*/
async revokePermission(adminKey: string, permissionId: string): Promise<void> {
try {
const response = await fetch(
`${this.baseUrl}/libra/api/v1/permissions/${permissionId}`,
{
method: 'DELETE',
headers: this.getHeaders(adminKey),
signal: AbortSignal.timeout(this.config?.apiConfig?.timeout || 30000)
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage =
errorData.detail || `Failed to revoke permission: ${response.statusText}`
throw new Error(errorMessage)
}
} catch (error) {
console.error('[ExpensesAPI] Error revoking permission:', error)
throw error
}
}
/**
* Get user's transactions from journal
*

View file

@ -28,25 +28,6 @@ export interface Account {
has_children?: boolean
}
/**
* Account with user-specific permission metadata
* (Will be available once libra API implements permissions)
*/
export interface AccountWithPermissions extends Account {
user_permissions?: PermissionType[]
inherited_from?: string
}
/**
* Permission types for account access control
*/
export enum PermissionType {
READ = 'read',
SUBMIT_EXPENSE = 'submit_expense',
SUBMIT_INCOME = 'submit_income',
MANAGE = 'manage'
}
/**
* Expense entry request payload
*/
@ -125,31 +106,6 @@ export interface UserInfo {
equity_account_name?: string
}
/**
* Account permission for user access control
*/
export interface AccountPermission {
id: string
user_id: string
account_id: string
permission_type: PermissionType
granted_at: string
granted_by: string
expires_at?: string
notes?: string
}
/**
* Grant permission request payload
*/
export interface GrantPermissionRequest {
user_id: string
account_id: string
permission_type: PermissionType
expires_at?: string
notes?: string
}
/**
* Transaction entry from journal (user view)
*/

View file

@ -12,10 +12,6 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import {
CheckCircle2,
Clock,
Flag,
XCircle,
RefreshCw,
Calendar,
Filter
@ -30,14 +26,26 @@ const isLoading = ref(false)
const dateRangeType = ref<number | 'custom'>(15) // 15, 30, 60, or 'custom'
const customStartDate = ref<string>('')
const customEndDate = ref<string>('')
const typeFilter = ref<'all' | 'income' | 'expense'>('all')
// Each chip is an inclusion toggle for one bucket of rows. Every row
// belongs to exactly one bucket (voided rows go to 'voided' regardless
// of their income/expense type). Default hides voided.
type Category = 'income' | 'expense' | 'voided'
const typeFilterOptions = [
{ label: 'All', value: 'all' as const },
{ label: 'Income', value: 'income' as const },
{ label: 'Expenses', value: 'expense' as const }
const activeCategories = ref<Set<Category>>(new Set<Category>(['income', 'expense']))
const categoryChips: { label: string; value: Category }[] = [
{ label: 'Income', value: 'income' },
{ label: 'Expenses', value: 'expense' },
{ label: 'Voided', value: 'voided' }
]
function toggleCategory(cat: Category) {
const next = new Set(activeCategories.value)
if (next.has(cat)) next.delete(cat)
else next.add(cat)
activeCategories.value = next
}
function isIncome(t: Transaction): boolean {
return t.tags?.includes('income-entry') ?? false
}
@ -46,6 +54,22 @@ function isExpense(t: Transaction): boolean {
return t.tags?.includes('expense-entry') ?? false
}
function isVoided(t: Transaction): boolean {
return t.tags?.includes('voided') ?? false
}
function isPending(t: Transaction): boolean {
return t.flag === '!' && !isVoided(t)
}
// Which chip bucket a row falls into. Voided always wins over type.
function getBucket(t: Transaction): Category | null {
if (isVoided(t)) return 'voided'
if (isIncome(t)) return 'income'
if (isExpense(t)) return 'expense'
return null
}
const walletKey = computed(() => user.value?.wallets?.[0]?.inkey)
// Fuzzy search state and configuration
@ -71,12 +95,13 @@ const searchOptions: FuzzySearchOptions<Transaction> = {
matchAllWhenSearchEmpty: true
}
// Transactions to display (search results or all transactions), filtered by type
// Transactions to display: row passes if its bucket's chip is active.
const transactionsToDisplay = computed(() => {
const base = searchResults.value.length > 0 ? searchResults.value : transactions.value
if (typeFilter.value === 'income') return base.filter(isIncome)
if (typeFilter.value === 'expense') return base.filter(isExpense)
return base
return base.filter(t => {
const bucket = getBucket(t)
return bucket !== null && activeCategories.value.has(bucket)
})
})
// Handle search results
@ -108,20 +133,28 @@ function formatAmount(amount: number): string {
return new Intl.NumberFormat('en-US').format(amount)
}
// Get status icon and color based on flag
function getStatusInfo(flag?: string) {
switch (flag) {
case '*':
return { icon: CheckCircle2, color: 'text-green-600', label: 'Cleared' }
case '!':
return { icon: Clock, color: 'text-orange-600', label: 'Pending' }
case '#':
return { icon: Flag, color: 'text-red-600', label: 'Flagged' }
case 'x':
return { icon: XCircle, color: 'text-muted-foreground', label: 'Voided' }
default:
return null
}
// Income gets a leading '+', expense a leading '-'.
function getAmountSign(t: Transaction): string {
if (isIncome(t)) return '+'
if (isExpense(t)) return '-'
return ''
}
// Color tint for the amount text. Voided entries drop to muted regardless
// of type since the strike-through carries the "ignore this" signal.
function getAmountColorClass(t: Transaction): string {
if (isVoided(t)) return 'line-through text-muted-foreground'
if (isIncome(t)) return 'text-green-600 dark:text-green-400'
if (isExpense(t)) return 'text-red-600 dark:text-red-400'
return ''
}
// Tags that drive other visual channels (border / sign / strike-through)
// suppressed from the badge row so it only carries user-added tags.
const TYPE_TAGS = new Set(['income-entry', 'expense-entry', 'voided'])
function getDisplayTags(t: Transaction): string[] {
return (t.tags ?? []).filter(tag => !TYPE_TAGS.has(tag))
}
// Load transactions
@ -224,19 +257,20 @@ onMounted(() => {
</Button>
</div>
<!-- Type Filter (All / Income / Expenses) -->
<!-- Category chips: each chip toggles inclusion of one bucket
of rows. Defaults: Income + Expenses on, Voided off. -->
<div class="flex items-center gap-2 flex-wrap">
<Filter class="h-4 w-4 text-muted-foreground" />
<Button
v-for="option in typeFilterOptions"
:key="option.value"
:variant="typeFilter === option.value ? 'default' : 'outline'"
v-for="chip in categoryChips"
:key="chip.value"
:variant="activeCategories.has(chip.value) ? 'default' : 'outline'"
size="sm"
class="h-7 md:h-8 px-3 text-xs"
@click="typeFilter = option.value"
@click="toggleCategory(chip.value)"
:disabled="isLoading"
>
{{ option.label }}
{{ chip.label }}
</Button>
</div>
@ -291,7 +325,7 @@ onMounted(() => {
Found {{ transactionsToDisplay.length }} matching transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
</span>
<span v-else>
{{ transactions.length }} transaction{{ transactions.length === 1 ? '' : 's' }}
{{ transactionsToDisplay.length }} transaction{{ transactionsToDisplay.length === 1 ? '' : 's' }}
</span>
</div>
@ -307,7 +341,9 @@ onMounted(() => {
<div v-else-if="transactionsToDisplay.length === 0" class="text-center py-12 px-4">
<p class="text-muted-foreground">No transactions found</p>
<p class="text-sm text-muted-foreground mt-2">
{{ searchResults.length > 0 ? 'Try a different search term' : 'Try selecting a different time period' }}
<template v-if="searchResults.length > 0">Try a different search term</template>
<template v-else-if="activeCategories.size === 0">Select a category above to see transactions</template>
<template v-else>Try selecting a different time period or toggling more categories</template>
</p>
</div>
@ -316,29 +352,19 @@ onMounted(() => {
<div
v-for="transaction in transactionsToDisplay"
:key="transaction.id"
:class="[
'border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors',
isIncome(transaction) && 'border-l-4 border-l-green-600',
isExpense(transaction) && 'border-l-4 border-l-red-600'
]"
class="border-b md:border md:rounded-lg p-4 hover:bg-muted/50 transition-colors"
>
<!-- Transaction Header -->
<div class="flex items-start justify-between gap-3 mb-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<!-- Status Icon -->
<component
v-if="getStatusInfo(transaction.flag)"
:is="getStatusInfo(transaction.flag)!.icon"
:class="[
'h-4 w-4 flex-shrink-0',
getStatusInfo(transaction.flag)!.color
]"
/>
<h3 class="font-medium text-sm sm:text-base truncate">
{{ transaction.description }}
</h3>
</div>
<h3
:class="[
'font-medium text-sm sm:text-base truncate mb-1',
isVoided(transaction) && 'line-through text-muted-foreground'
]"
>
{{ transaction.description }}
</h3>
<p class="text-xs sm:text-sm text-muted-foreground">
{{ formatDate(transaction.date) }}
</p>
@ -346,11 +372,17 @@ onMounted(() => {
<!-- Amount -->
<div class="text-right flex-shrink-0">
<p class="font-semibold text-sm sm:text-base">
{{ formatAmount(transaction.amount) }} sats
<p :class="['font-semibold text-sm sm:text-base', getAmountColorClass(transaction)]">
{{ getAmountSign(transaction) }}{{ formatAmount(transaction.amount) }} sats
</p>
<p v-if="transaction.fiat_amount" class="text-xs text-muted-foreground">
{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
<p
v-if="transaction.fiat_amount"
:class="[
'text-xs',
getAmountColorClass(transaction) || 'text-muted-foreground'
]"
>
{{ getAmountSign(transaction) }}{{ transaction.fiat_amount.toFixed(2) }} {{ transaction.fiat_currency }}
</p>
</div>
</div>
@ -367,17 +399,42 @@ onMounted(() => {
<span class="font-medium">Ref:</span> {{ transaction.reference }}
</div>
<!-- Tags -->
<div v-if="transaction.tags && transaction.tags.length > 0" class="flex flex-wrap gap-1 mt-2">
<!-- Badges: type (Income / Expense) + status (Voided / Pending,
mutually exclusive) + any user-added tags. -->
<div class="flex flex-wrap gap-1 mt-2">
<Badge
v-for="tag in transaction.tags"
v-if="isIncome(transaction)"
variant="secondary"
class="text-xs bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300"
>
Income
</Badge>
<Badge
v-else-if="isExpense(transaction)"
variant="secondary"
class="text-xs bg-orange-100 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300"
>
Expense
</Badge>
<Badge
v-if="isVoided(transaction)"
variant="outline"
class="text-xs text-muted-foreground"
>
Voided
</Badge>
<Badge
v-else-if="isPending(transaction)"
variant="secondary"
class="text-xs bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300"
>
Pending approval
</Badge>
<Badge
v-for="tag in getDisplayTags(transaction)"
:key="tag"
variant="secondary"
:class="[
'text-xs',
tag === 'income-entry' && 'bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300',
tag === 'expense-entry' && 'bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300'
]"
class="text-xs"
>
{{ tag }}
</Badge>