Skip to content

feat(admin): Serve the admin console from the shared layer - #114

Merged
nfebe merged 2 commits into
devfrom
feat/welcome-email-feedback
Sep 21, 2026
Merged

nfebe merged 2 commits into
devfrom
feat/welcome-email-feedback

Conversation

@nfebe

@nfebe nfebe commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

The admin console was a single page in this app, so its metrics, columns and drilldowns
could not be reused. It now comes from @whilesmart/eloquent-admin-ui, and Trakli supplies
its brand, navigation, user columns and drilldown targets as configuration.

Users can send feedback and read the status of what they sent. Product analytics are
collected once consent is given.

The lint and format checks were already failing on dev, in files this work does not
otherwise touch. The first commit clears them so the rest can be read against a green base.

@sourceant

sourceant Bot commented Sep 21, 2026

Copy link
Copy Markdown

Code Review Summary

✨ The admin console moves out of this app and onto the shared @whilesmart/eloquent-admin-ui Nuxt layer, with Trakli supplying its brand, navigation, user columns and metric drilldowns as runtimeConfig.public configuration. pages/admin/index.vue (~1,100 lines of rail, KPIs, charts, rankings, user table and AI-usage sections) is deleted; pages/admin/outreach.vue is re-homed onto the shared layer and rebuilt from @whilesmart/design primitives with explicit loading, error, empty and retry states. Brand identity becomes design-system tokens in assets/css/design-brand.css, components/admin/OutreachComposer.vue drops its hand-rolled inputs, buttons, chips and dropdown for shared components, and plugins/adminViewer.ts publishes the signed-in user into shared admin.viewer state.

Two product-facing pieces ship alongside: users can send feedback and read the status of what they sent (pages/feedback.vue, composables/useFeedback.ts, a /feedback entry in the avatar menu, plus full feedback/admin strings in i18n/locales/en.json with legacy: false and an English fallback), and consent-gated product analytics arrive via plugins/engagement.client.ts, wired to /engagement/events behind NUXT_PUBLIC_ENGAGEMENT_SITE_KEY (empty means nothing is collected) and tracking route changes through router.afterEach. Auth handling is reworked: useAuth syncs state unconditionally, middleware/auth.ts fetches the user when a token exists but auth state is unresolved, and middleware/admin.ts gates on token presence. Dependencies are added, the package moves to 2.0.0-beta.2, unused locale keys are removed and two budget help strings are reworded. The first commit clears pre-existing lint/format failures on dev so the rest reads against a green base. No review findings were supplied for this change, so no improvements or regressions are listed.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread composables/useAuth.ts
Comment on lines -34 to -36
// Auto-sync auth state on client-side
if (typeof window !== 'undefined') {
syncAuthState();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This call previously ran behind a typeof window !== 'undefined' guard (the guard and the // Auto-sync auth state on client-side comment were both removed here). syncAuthState() reads persisted auth state, and this composable is now also invoked from route middleware, which executes during SSR. Calling it unconditionally on the server risks touching browser-only storage during server render. Keep the client-only guard so SSR is unaffected.

Suggested change
// Auto-sync auth state on client-side
if (typeof window !== 'undefined') {
syncAuthState();
+ if (import.meta.client) {
+ syncAuthState();
+ }

Comment thread plugins/engagement.client.ts Outdated

const router = useRouter();
let currentPage = useRoute().fullPath;
router.afterEach((to) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

router.afterEach(...) returns an unsubscribe function that is discarded, so the navigation hook stays registered until the process ends. Since the plugin already has an app:beforeUnmount teardown point, capture the unsubscriber and call it there so teardown is symmetric with setup.

Suggested change
router.afterEach((to) => {
const stopTracking = router.afterEach((to) => {
if (to.fullPath === currentPage) return;
currentPage = to.fullPath;
engagement.page();
});
nuxtApp.hook('app:beforeUnmount', () => {
stopTracking();
engagement.destroy();
});

Comment thread pages/feedback.vue Outdated
><span>{{ t('Message') }}</span
><textarea v-model="form.message" required rows="7" maxlength="5000" />
</label>
<TButton :text="t('Send feedback')" :loading="sending" />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The form submits via @submit.prevent="submit", but the only control inside it is <TButton>. If TButton renders a <button> with type="button" (a common default for wrapper components), the submit event never fires and clicking "Send feedback" does nothing. Making the submit intent explicit is safe and unambiguous.

Suggested change
<TButton :text="t('Send feedback')" :loading="sending" />
<TButton type="submit" :text="t('Send feedback')" :loading="sending" />

Comment thread pages/feedback.vue
>
<div class="history">
<article v-for="item in items" :key="item.id">
<span class="status">{{ item.status.replaceAll('_', ' ') }}</span>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

item.status.replaceAll(...) is only safe when status is always a non-null string. If the feedback endpoint ever returns a record whose status is absent (e.g. a still-queued item), the render throws and the whole history list fails to mount. Guard the access defensively; the fallback is cosmetic and cannot mask a real status.

Suggested change
<span class="status">{{ item.status.replaceAll('_', ' ') }}</span>
<span class="status">{{ (item.status ?? '').replaceAll('_', ' ') }}</span>

Comment thread middleware/auth.ts Outdated

export default defineNuxtRouteMiddleware(async (to, _from) => {
const { isAuthenticated } = useAuth();
const { token, isAuthenticated, fetchUser, syncAuthState } = useAuth();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useAuth() on line 5 now runs syncAuthState() itself (see the added syncAuthState(); in composables/useAuth.ts), so this explicit call repeats the exact same hydration step in the same tick. The identical line was also added to middleware/admin.ts, which means the same responsibility is now implemented in three places: the composable, this middleware, and the admin middleware. The two middleware are otherwise near-identical copies of each other, so they will drift independently. The composable is invoked on the line above, so removing the explicit call keeps behaviour identical while leaving useAuth as the single owner of the sync; middleware/admin.ts should drop its copy as well.

Suggested change
const { token, isAuthenticated, fetchUser, syncAuthState } = useAuth();
+ const { token, isAuthenticated, fetchUser } = useAuth();

siteKey
});

const router = useRouter();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first page view of every session is never reported. currentPage is seeded with the route the user landed on, and router.afterEach only fires on subsequent navigations, so the entry page is silently dropped from analytics. Seed an empty marker and emit one page event for the initial route so the landing page is counted like any other navigation.

Suggested change
const router = useRouter();
+ const router = useRouter();
+ let currentPage = '';
+ // `afterEach` does not fire for the route the app booted on, so report it here.
+ engagement.page();
+ currentPage = useRoute().fullPath;
+ router.afterEach((to) => {
+ if (to.fullPath === currentPage) return;
+ currentPage = to.fullPath;
+ engagement.page();
+ });

The console was a single page in this app, so every metric, column and
drilldown it showed was hard-coded here and could not be reused. It now
comes from the shared admin layer, with Trakli supplying its own brand,
navigation, user columns and drilldown targets as configuration.

Users can send feedback and read the status of what they sent, and
admins triage it. Product analytics are collected once consent is given.
@nfebe
nfebe force-pushed the feat/welcome-email-feedback branch from 81b0c35 to 79ca7fc Compare September 21, 2026 10:25
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 21, 2026

Copy link
Copy Markdown

Deploying trakli-dev with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7a64aac
Status: ✅  Deploy successful!
Preview URL: https://dcba1cbb.trakli-dev.pages.dev
Branch Preview URL: https://feat-welcome-email-feedback.trakli-dev.pages.dev

View logs

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 21, 2026

Copy link
Copy Markdown

Deploying webui with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7a64aac
Status: ✅  Deploy successful!
Preview URL: https://95e155ef.webui-9fh.pages.dev
Branch Preview URL: https://feat-welcome-email-feedback.webui-9fh.pages.dev

View logs

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

Comment thread .env.example
@@ -1,4 +1,5 @@
NUXT_PUBLIC_API_BASE_URL=http://localhost:8000/api/v1
NUXT_PUBLIC_ENGAGEMENT_SITE_KEY=trakli-dashboard

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new variable has a non-obvious effect: plugins/engagement.client.ts returns early when no site key is configured, so an empty value silently disables analytics. Every other variable in this file carries a short explanatory comment; add one here too so operators know what leaving it empty does.

Suggested change
NUXT_PUBLIC_ENGAGEMENT_SITE_KEY=trakli-dashboard
+# Product analytics. Leave empty to disable collection: the engagement plugin
+# returns early when no site key is configured.
+NUXT_PUBLIC_ENGAGEMENT_SITE_KEY=trakli-dashboard

export interface FeedbackItem {
id: number;
type: string;
status: string;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

subject is already nullable, which shows this payload can omit fields, yet status is typed as a non-null string. The feedback list renders item.status.replaceAll(...) directly, so a record returned without a status will throw at render time. Typing it as string | null forces every call site to handle the missing case (the render site should fall back to a neutral label rather than assume a status).

Suggested change
status: string;
+ status: string | null;

Comment thread pages/feedback.vue
<span class="status">{{ item.status.replaceAll('_', ' ') }}</span>
<h3 v-if="item.subject">{{ item.subject }}</h3>
<p>{{ item.message }}</p>
<small>{{ new Date(item.created_at).toLocaleString() }}</small>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

created_at comes straight off the API response and is formatted without a guard. If a record is returned without a date (or with an unparseable one), new Date(...).toLocaleString() renders the literal string Invalid Date rather than throwing, but it is still wrong output in the history list. Guard the access the same way the status field is guarded.

Suggested change
<small>{{ new Date(item.created_at).toLocaleString() }}</small>
<small>{{ item.created_at ? new Date(item.created_at).toLocaleString() : '' }}</small>

Comment thread pages/feedback.vue
import TCard from '@/components/TCard.vue';
import TButton from '@/components/TButton.vue';
import ComponentLoader from '@/components/ComponentLoader.vue';
import { useFeedback, type FeedbackItem } from '@/composables/useFeedback';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useFeedback is imported explicitly on this line, but useNotifications() is called on line 76 without an import. Sibling pages (e.g. pages/budgets/index.vue) import useNotifications from @/composables/useNotifications explicitly, so this file is inconsistent and would throw useNotifications is not defined if auto-import is ever disabled. Import it alongside useFeedback.

Suggested change
import { useFeedback, type FeedbackItem } from '@/composables/useFeedback';
import { useFeedback, type FeedbackItem } from '@/composables/useFeedback';
import { useNotifications } from '@/composables/useNotifications';

export interface FeedbackItem {
id: number;
type: string;
status: string;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FeedbackItem is the app's declaration of the producer's response shape, and it types status as a mandatory string. The single consumer renders item.status.replaceAll(...), i.e. it already treats the field as possibly absent. If the record can be returned without a status, the type is wrong and it silently promises the consumer something the producer does not guarantee. Making the field nullable moves the check to compile time, so a missing status becomes a type error instead of a runtime throw during render.

Suggested change
status: string;
status: string | null;

Comment thread middleware/admin.ts
if (!isAuthenticated.value) {
syncAuthState();

if (!token.value) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This twin middleware now decides authorization differently from middleware/auth.ts. auth.ts hydrates the user (if (token.value && !isAuthenticated.value) await fetchUser();) and then gates on isAuthenticated.value; admin.ts gates on token.value alone and never hydrates in the shown hunk. syncAuthState() restores the token from persisted storage, so a token can be present while no user object has been loaded. Gating on token presence admits the admin route in that window, and the downstream admin-role check (which reads user) then runs against a null user — a path that bypasses the shared authentication contract the rest of the app relies on. Mirror auth.ts so both middleware share one authorization contract.

Suggested change
if (!token.value) {
if (token.value && !isAuthenticated.value) {
await fetchUser();
}
if (!isAuthenticated.value) {

The only control in the feedback form rendered as an ordinary button, so
clicking it did nothing and no message could be sent. The page now also
imports what it uses rather than relying on an ambient global, so it can
be mounted outside the framework.

Analytics teardown unregisters the navigation hook it registered, and
the two route guards no longer repeat work the composable already does
on creation.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. No specific code suggestions were generated. See the overview comment for a summary.

@nfebe
nfebe merged commit 849c883 into dev Sep 21, 2026
6 checks passed
@nfebe
nfebe deleted the feat/welcome-email-feedback branch September 21, 2026 17:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant