Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
NUXT_PUBLIC_API_BASE_URL=http://localhost:8000/api/v1
# Product analytics. Leave empty to collect nothing: the engagement plugin
# stops before starting when no site key is set.
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


# Real-time chat streaming (Laravel Reverb). Leave REVERB_KEY empty to run on
# polling only. Host/port are where the browser reaches the Reverb server (the
Expand All @@ -7,4 +10,3 @@ NUXT_PUBLIC_REVERB_KEY=your-reverb-app-key
NUXT_PUBLIC_REVERB_HOST=localhost
NUXT_PUBLIC_REVERB_PORT=6001
NUXT_PUBLIC_REVERB_SCHEME=http

36 changes: 36 additions & 0 deletions assets/css/design-brand.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Trakli's brand, expressed in the design system's brand contract.
*
* Only tokens listed in @whilesmart/design/tokens/brand.css belong here. Anything else the
* admin needs is derived from these, so one value changes the whole console.
*
* Every text pair here is held to 4.5:1 in both themes by a test.
*/

:root {
--ds-brand-primary-50: #f0f5f2;
--ds-brand-primary-100: #d8e8e1;
--ds-brand-primary-200: #b6d8c9;
--ds-brand-primary-300: #89c8ab;
--ds-brand-primary-400: #57c192;
--ds-brand-primary-500: #27a56c;
--ds-brand-primary-600: #129158;
--ds-brand-primary-700: #09864e;
--ds-brand-primary-800: #047844;
--ds-brand-primary-900: #02502d;

--ds-brand-secondary-50: #edf7f6;
--ds-brand-secondary-100: #d2efeb;
--ds-brand-secondary-200: #aae4dc;
--ds-brand-secondary-300: #78d9cc;
--ds-brand-secondary-400: #43d0bd;
--ds-brand-secondary-500: #24b29f;
--ds-brand-secondary-600: #189584;
--ds-brand-secondary-700: #0e8171;
--ds-brand-secondary-800: #07695c;
--ds-brand-secondary-900: #024b41;

--ds-brand-font-sans:
'Ubuntu', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
}
11 changes: 10 additions & 1 deletion components/TAvatar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
<ShieldCheck class="menu-icon" />
<span>{{ t('Admin') }}</span>
</button>
<button class="menu-item" @click="goToFeedback">
<MessageSquare class="menu-icon" />
<span>{{ t('Send feedback') }}</span>
</button>
<button class="menu-item menu-item--danger" @click="handleLogout">
<LogOut class="menu-icon" />
<span>{{ t('Logout') }}</span>
Expand All @@ -37,7 +41,7 @@

<script setup>
import { useRouter } from 'vue-router';
import { Settings, LogOut, ShieldCheck } from 'lucide-vue-next';
import { Settings, LogOut, ShieldCheck, MessageSquare } from 'lucide-vue-next';
import { useAuth } from '@/composables/useAuth';
import { useDropdown } from '@/composables/useDropdown';

Expand Down Expand Up @@ -76,6 +80,11 @@ const goToAdmin = () => {
router.push('/admin');
};

const goToFeedback = () => {
close();
router.push('/feedback');
};

const handleLogout = async () => {
close();
await logout();
Expand Down
164 changes: 38 additions & 126 deletions components/admin/OutreachComposer.vue
Original file line number Diff line number Diff line change
@@ -1,30 +1,19 @@
<template>
<div class="composer">
<form class="composer__form" @submit.prevent="onSend">
<label class="field">
<span class="field__label">{{ t('Subject') }}</span>
<input v-model="form.subject" class="field__input" :placeholder="t('Subject')" required />
</label>
<DsInput v-model="form.subject" :label="t('Subject')" :placeholder="t('Subject')" required />

<div class="field">
<span class="field__label">{{ t('Message') }}</span>
<div class="md-toolbar">
<button
v-for="f in formats"
:key="f.label"
type="button"
class="md-btn"
:title="t(f.label)"
@click="f.action()"
>
<DsIconButton v-for="f in formats" :key="f.label" :title="t(f.label)" @click="f.action()">
<component :is="f.icon" class="md-btn__icon" />
</button>
</DsIconButton>
</div>
<textarea
<DsTextarea
ref="bodyRef"
v-model="form.body"
class="field__textarea"
rows="8"
:rows="8"
:placeholder="t('Write your message. Markdown is supported.')"
required
/>
Expand All @@ -47,44 +36,30 @@

<div class="tokens">
<span class="tokens__hint">{{ t('Personalize with:') }}</span>
<button
v-for="token in tokens"
:key="token"
type="button"
class="token"
@click="insertToken(token)"
>
<DsChip v-for="token in tokens" :key="token" clickable @click="insertToken(token)">
{{ token }}
</button>
</DsChip>
</div>

<div class="cta-row">
<label class="field">
<span class="field__label">{{ t('Button label') }}</span>
<input
v-model="form.cta_label"
class="field__input"
:placeholder="t('e.g. Open Trakli')"
/>
</label>
<label class="field">
<span class="field__label">{{ t('Button link') }}</span>
<input v-model="form.cta_url" class="field__input" placeholder="https://" />
</label>
<DsInput
v-model="form.cta_label"
:label="t('Button label')"
:placeholder="t('e.g. Open Trakli')"
/>
<DsInput
v-model="form.cta_url"
:label="t('Button link')"
placeholder="https://"
type="url"
/>
</div>

<label class="field">
<span class="field__label">{{ t('Audience') }}</span>
<select v-model="form.audience" class="field__input">
<option v-for="opt in audiences" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</label>
<DsDropdown v-model="form.audience" :options="audiences" :label="t('Audience')" />

<div v-if="form.audience === 'specific'" class="field">
<span class="field__label">{{ t('Choose recipients') }}</span>
<input v-model="userQuery" class="field__input" :placeholder="t('Search users')" />
<DsInput v-model="userQuery" :placeholder="t('Search users')" />
<div class="recipients">
<label v-for="u in filteredUsers" :key="u.id" class="recipient">
<input v-model="form.user_ids" type="checkbox" :value="u.id" />
Expand Down Expand Up @@ -114,29 +89,16 @@
<div v-if="confirming" class="confirm">
<p class="confirm__text">{{ confirmText }}</p>
<div class="confirm__actions">
<TButton
:text="t('Cancel')"
variant="outline"
size="small"
:full-width="false"
@click="confirming = false"
/>
<TButton
:text="t('Send now')"
size="small"
:full-width="false"
:loading="sending"
@click="send"
/>
<DsButton variant="secondary" size="sm" @click="confirming = false">{{
t('Cancel')
}}</DsButton>
<DsButton size="sm" :loading="sending" @click="send">{{ t('Send now') }}</DsButton>
</div>
</div>
<div v-else class="composer__actions">
<TButton
type="submit"
:text="form.audience === 'test' ? t('Send test to me') : t('Send')"
:full-width="false"
:loading="sending"
/>
<DsButton type="submit" :loading="sending">{{
form.audience === 'test' ? t('Send test to me') : t('Send')
}}</DsButton>
</div>
</form>

Expand Down Expand Up @@ -168,7 +130,14 @@ import {
ListOrdered as ListOrderedIcon,
Link2 as LinkIcon
} from 'lucide-vue-next';
import TButton from '@/components/TButton.vue';
import {
DsButton,
DsChip,
DsDropdown,
DsIconButton,
DsInput,
DsTextarea
} from '@whilesmart/design';
import { adminApi, type OutreachAudience, type AdminUser } from '@/services/api/adminApi';

const { t } = useI18n();
Expand Down Expand Up @@ -285,10 +254,10 @@ const insertToken = (token: string) => {
form.body = `${form.body}${form.body.endsWith(' ') || !form.body ? '' : ' '}${token} `;
};

const bodyRef = ref<HTMLTextAreaElement | null>(null);
const bodyRef = ref<{ textarea: HTMLTextAreaElement | null } | null>(null);

const surround = (before: string, after: string) => {
const el = bodyRef.value;
const el = bodyRef.value?.textarea;
if (!el) return;
const start = el.selectionStart;
const end = el.selectionEnd;
Expand All @@ -302,7 +271,7 @@ const surround = (before: string, after: string) => {
};

const prefixLines = (prefix: string) => {
const el = bodyRef.value;
const el = bodyRef.value?.textarea;
if (!el) return;
const lineStart = form.body.lastIndexOf('\n', el.selectionStart - 1) + 1;
const block = form.body.slice(lineStart, el.selectionEnd);
Expand Down Expand Up @@ -396,30 +365,6 @@ const send = async () => {
color: $text-secondary;
}

.field__input,
.field__textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid $border-color;
border-radius: $radius-lg;
padding: 0.6rem 0.75rem;
font-size: $font-size-sm;
font-family: inherit;
color: $text-primary;
background: $bg-white;

&:focus {
outline: none;
border-color: $primary;
}
}

.field__textarea {
resize: vertical;
border-top-left-radius: 0;
border-top-right-radius: 0;
}

.md-toolbar {
display: flex;
gap: 0.15rem;
Expand All @@ -430,24 +375,6 @@ const send = async () => {
background: $bg-light;
}

.md-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: $radius-md;
background: transparent;
color: $text-secondary;
cursor: pointer;

&:hover {
background: $bg-white;
color: $primary;
}
}

.md-btn__icon {
width: 15px;
height: 15px;
Expand All @@ -465,21 +392,6 @@ const send = async () => {
color: $text-muted;
}

.token {
border: 1px solid $border-color;
background: $bg-light;
border-radius: $radius-md;
padding: 0.2rem 0.5rem;
font-size: $font-size-xs;
font-family: monospace;
color: $primary-dark;
cursor: pointer;

&:hover {
border-color: $primary;
}
}

.cta-row {
display: grid;
grid-template-columns: 1fr 1.3fr;
Expand Down
2 changes: 1 addition & 1 deletion components/auth/AuthSocialLogin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const handleGoogleLogin = async () => {
query: { state: newState }
});
if (response.data.url) {
window.location.href = response.data.url;
await navigateTo(response.data.url, { external: true });
}
} catch (error) {
console.error('Failed to get Google redirect URL', error);
Expand Down
5 changes: 1 addition & 4 deletions composables/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,7 @@ export const useAuth = () => {
}
};

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

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();
+ }

}
syncAuthState();

const login = async (credentials: { email: string; password: string }) => {
try {
Expand Down
31 changes: 31 additions & 0 deletions composables/useFeedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
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;

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;

subject: string | null;
message: string;
created_at: string;
}

export function useFeedback() {
const api = useApi();

async function list(): Promise<FeedbackItem[]> {
const response = await api<{ data: FeedbackItem[] }>('/feedback');
return response.data;
}

async function send(payload: {
type: string;
subject?: string;
message: string;
}): Promise<FeedbackItem> {
const response = await api<{ data: FeedbackItem }>('/feedback', {
method: 'POST',
body: payload
});
return response.data;
}

return { list, send };
}
6 changes: 6 additions & 0 deletions i18n/i18n.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default defineI18nConfig(() => ({
legacy: false,
// English is the source language: a key missing from another locale renders its English
// text instead of the key, and without a console warning for every string.
fallbackLocale: 'en'
}));
Loading
Loading