feat(mail): center-align action button in email template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 00:49:41 +02:00
parent 172a6a12d3
commit ef195a6777
14 changed files with 336 additions and 141 deletions

View File

@@ -25,6 +25,7 @@ declare module 'vue' {
AppStepper: typeof import('./src/@core/components/AppStepper.vue')['default']
AppTextarea: typeof import('./src/@core/components/app-form-elements/AppTextarea.vue')['default']
AppTextField: typeof import('./src/@core/components/app-form-elements/AppTextField.vue')['default']
AssignPersonDialog: typeof import('./src/components/shifts/AssignPersonDialog.vue')['default']
AssignShiftDialog: typeof import('./src/components/sections/AssignShiftDialog.vue')['default']
CardStatisticsHorizontal: typeof import('./src/@core/components/cards/CardStatisticsHorizontal.vue')['default']
CardStatisticsVertical: typeof import('./src/@core/components/cards/CardStatisticsVertical.vue')['default']
@@ -56,25 +57,34 @@ declare module 'vue' {
EditOrganisationDialog: typeof import('./src/components/organisations/EditOrganisationDialog.vue')['default']
EditPersonDialog: typeof import('./src/components/persons/EditPersonDialog.vue')['default']
EditSectionDialog: typeof import('./src/components/sections/EditSectionDialog.vue')['default']
EmailBrandingTab: typeof import('./src/components/organisation/EmailBrandingTab.vue')['default']
EnableOneTimePasswordDialog: typeof import('./src/components/dialogs/EnableOneTimePasswordDialog.vue')['default']
ErrorHeader: typeof import('./src/components/ErrorHeader.vue')['default']
EventMetricCards: typeof import('./src/components/events/EventMetricCards.vue')['default']
EventTabsNav: typeof import('./src/components/events/EventTabsNav.vue')['default']
I18n: typeof import('./src/@core/components/I18n.vue')['default']
ImportFromEventDialog: typeof import('./src/components/event/ImportFromEventDialog.vue')['default']
InviteMemberDialog: typeof import('./src/components/members/InviteMemberDialog.vue')['default']
MoreBtn: typeof import('./src/@core/components/MoreBtn.vue')['default']
Notifications: typeof import('./src/@core/components/Notifications.vue')['default']
OrganisationSwitcher: typeof import('./src/components/layout/OrganisationSwitcher.vue')['default']
PaymentProvidersDialog: typeof import('./src/components/dialogs/PaymentProvidersDialog.vue')['default']
PersonDetailPanel: typeof import('./src/components/persons/PersonDetailPanel.vue')['default']
PersonTagsTab: typeof import('./src/components/organisation/PersonTagsTab.vue')['default']
ProductDescriptionEditor: typeof import('./src/@core/components/ProductDescriptionEditor.vue')['default']
RegistrationFieldCard: typeof import('./src/components/event/RegistrationFieldCard.vue')['default']
RegistrationFieldFormDialog: typeof import('./src/components/event/RegistrationFieldFormDialog.vue')['default']
RegistrationFieldTemplatesTab: typeof import('./src/components/organisation/RegistrationFieldTemplatesTab.vue')['default']
RegistrationLinkCard: typeof import('./src/components/events/RegistrationLinkCard.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ScrollToTop: typeof import('./src/@core/components/ScrollToTop.vue')['default']
SectionsShiftsPanel: typeof import('./src/components/sections/SectionsShiftsPanel.vue')['default']
ShareProjectDialog: typeof import('./src/components/dialogs/ShareProjectDialog.vue')['default']
ShiftDetailPanel: typeof import('./src/components/shifts/ShiftDetailPanel.vue')['default']
Shortcuts: typeof import('./src/@core/components/Shortcuts.vue')['default']
TablePagination: typeof import('./src/@core/components/TablePagination.vue')['default']
TemplatePickerDialog: typeof import('./src/components/event/TemplatePickerDialog.vue')['default']
ThemeSwitcher: typeof import('./src/@core/components/ThemeSwitcher.vue')['default']
TiptapEditor: typeof import('./src/@core/components/TiptapEditor.vue')['default']
TwoFactorAuthDialog: typeof import('./src/components/dialogs/TwoFactorAuthDialog.vue')['default']

11
apps/app/env.d.ts vendored
View File

@@ -1,4 +1,15 @@
import 'vue-router'
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_APP_NAME: string
readonly VITE_PORTAL_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module 'vue-router' {
interface RouteMeta {
action?: string

View File

@@ -47,3 +47,26 @@
.bg-custom-background {
background-color: rgb(var(--v-table-header-color));
}
// ---------------------------------------------------------------------------
// Vuexy (Bootstrap 5) parity — spacing + horizontal rules in cards
// (e.g. form-layouts-horizontal “Form separator”)
// ---------------------------------------------------------------------------
.my-6 {
margin-block-end: 1.5rem !important;
margin-block-start: 1.5rem !important;
}
.mx-n6 {
margin-inline-end: -1.5rem !important;
margin-inline-start: -1.5rem !important;
}
// BS: `.card hr { color: var(--bs-card-border-color); }` — Vuetify card border token
.v-card .v-card-text hr {
border: 0;
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
color: rgba(var(--v-border-color), var(--v-border-opacity));
opacity: 1;
}

View File

@@ -1,3 +1,58 @@
.v-card-subtitle {
color: rgba(var(--v-theme-on-background), 0.55);
}
/* Card border shadow — colored bottom border with hover effect */
.v-card[class*="card-border-shadow-"] {
position: relative;
overflow: visible;
border-block-end: none;
transition: box-shadow 0.3s ease;
&::after {
position: absolute;
border-radius: inherit;
block-size: 100%;
border-block-end: 0.125rem solid var(--card-border-bottom-color, rgba(var(--v-theme-on-surface), 0.12));
content: "";
inline-size: 100%;
inset-block-end: 0;
inset-inline-start: 0;
pointer-events: none;
transition: border-color 0.3s ease, border-width 0.3s ease;
}
&:hover {
box-shadow: 0 0.25rem 1.125rem rgba(var(--v-theme-on-surface), 0.16) !important;
&::after {
border-color: var(--card-hover-border-bottom-color, rgba(var(--v-theme-on-surface), 0.3));
border-block-end-width: 0.1875rem;
}
}
}
.v-card.card-border-shadow-primary {
--card-border-bottom-color: rgba(var(--v-theme-primary), 0.38);
--card-hover-border-bottom-color: rgb(var(--v-theme-primary));
}
.v-card.card-border-shadow-warning {
--card-border-bottom-color: rgba(var(--v-theme-warning), 0.38);
--card-hover-border-bottom-color: rgb(var(--v-theme-warning));
}
.v-card.card-border-shadow-info {
--card-border-bottom-color: rgba(var(--v-theme-info), 0.38);
--card-hover-border-bottom-color: rgb(var(--v-theme-info));
}
.v-card.card-border-shadow-success {
--card-border-bottom-color: rgba(var(--v-theme-success), 0.38);
--card-hover-border-bottom-color: rgb(var(--v-theme-success));
}
.v-card.card-border-shadow-error {
--card-border-bottom-color: rgba(var(--v-theme-error), 0.38);
--card-hover-border-bottom-color: rgb(var(--v-theme-error));
}

View File

@@ -80,33 +80,37 @@ const shiftFillPercent = computed(() => {
md="3"
>
<VCard
class="cursor-pointer"
hover
:class="[
'cursor-pointer h-100',
stats.persons_approved_without_shift > 0 ? 'card-border-shadow-warning' : 'card-border-shadow-success',
]"
@click="navigateTo('events-id-persons')"
>
<VCardText class="d-flex align-center gap-x-3">
<VAvatar
:color="stats.persons_approved_without_shift > 0 ? 'warning' : 'success'"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-user-exclamation"
size="28"
/>
</VAvatar>
<div>
<h4 class="text-h4">
<VCardText>
<div class="d-flex align-center mb-1">
<VAvatar
:color="stats.persons_approved_without_shift > 0 ? 'warning' : 'success'"
variant="tonal"
size="44"
rounded
class="me-4"
>
<VIcon
icon="tabler-user-exclamation"
size="28"
/>
</VAvatar>
<h4 class="text-h4 mb-0">
{{ stats.persons_approved_without_shift }}
</h4>
<p class="text-body-2 text-medium-emphasis mb-0">
goedgekeurd zonder shift
</p>
<p class="text-caption text-disabled mb-0">
van {{ stats.persons_approved }} goedgekeurde personen
</p>
</div>
<p class="mb-1">
Goedgekeurd zonder shift
</p>
<p class="mb-0">
<span class="text-heading fw-medium me-2">{{ stats.persons_approved }}</span>
<span class="text-body-secondary text-sm">goedgekeurde personen</span>
</p>
</VCardText>
</VCard>
</VCol>
@@ -118,30 +122,36 @@ const shiftFillPercent = computed(() => {
md="3"
>
<VCard
class="cursor-pointer"
hover
:class="[
'cursor-pointer h-100',
stats.persons_pending > 0 ? 'card-border-shadow-warning' : 'card-border-shadow-success',
]"
@click="navigateTo('events-id-persons')"
>
<VCardText class="d-flex align-center gap-x-3">
<VAvatar
:color="stats.persons_pending > 0 ? 'warning' : 'success'"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-clock"
size="28"
/>
</VAvatar>
<div>
<h4 class="text-h4">
<VCardText>
<div class="d-flex align-center mb-1">
<VAvatar
:color="stats.persons_pending > 0 ? 'warning' : 'success'"
variant="tonal"
size="44"
rounded
class="me-4"
>
<VIcon
icon="tabler-clock"
size="28"
/>
</VAvatar>
<h4 class="text-h4 mb-0">
{{ stats.persons_pending }}
</h4>
<p class="text-body-2 text-medium-emphasis mb-0">
wachtende goedkeuringen
</p>
</div>
<p class="mb-1">
Wachtende goedkeuringen
</p>
<p class="mb-0">
<span class="text-body-secondary text-sm">te beoordelen aanmeldingen</span>
</p>
</VCardText>
</VCard>
</VCol>
@@ -153,30 +163,36 @@ const shiftFillPercent = computed(() => {
md="3"
>
<VCard
class="cursor-pointer"
hover
:class="[
'cursor-pointer h-100',
stats.pending_identity_matches > 0 ? 'card-border-shadow-info' : 'card-border-shadow-success',
]"
@click="navigateTo('events-id-persons')"
>
<VCardText class="d-flex align-center gap-x-3">
<VAvatar
:color="stats.pending_identity_matches > 0 ? 'info' : 'success'"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-user-search"
size="28"
/>
</VAvatar>
<div>
<h4 class="text-h4">
<VCardText>
<div class="d-flex align-center mb-1">
<VAvatar
:color="stats.pending_identity_matches > 0 ? 'info' : 'success'"
variant="tonal"
size="44"
rounded
class="me-4"
>
<VIcon
icon="tabler-user-search"
size="28"
/>
</VAvatar>
<h4 class="text-h4 mb-0">
{{ stats.pending_identity_matches }}
</h4>
<p class="text-body-2 text-medium-emphasis mb-0">
onopgeloste matches
</p>
</div>
<p class="mb-1">
Onopgeloste matches
</p>
<p class="mb-0">
<span class="text-body-secondary text-sm">identiteitscontrole nodig</span>
</p>
</VCardText>
</VCard>
</VCol>
@@ -188,36 +204,37 @@ const shiftFillPercent = computed(() => {
md="3"
>
<VCard
class="cursor-pointer"
hover
:class="[
'cursor-pointer h-100',
`card-border-shadow-${shiftFillColor}`,
]"
@click="navigateTo('events-id-sections')"
>
<VCardText class="d-flex align-center gap-x-3">
<VAvatar
:color="shiftFillColor"
variant="tonal"
size="44"
rounded
>
<VIcon
icon="tabler-calendar-check"
size="28"
/>
</VAvatar>
<div class="flex-grow-1">
<h4 class="text-h4">
<VCardText>
<div class="d-flex align-center mb-1">
<VAvatar
:color="shiftFillColor"
variant="tonal"
size="44"
rounded
class="me-4"
>
<VIcon
icon="tabler-calendar-check"
size="28"
/>
</VAvatar>
<h4 class="text-h4 mb-0">
{{ stats.shifts_filled }}/{{ stats.shifts_total }}
</h4>
<p class="text-body-2 text-medium-emphasis mb-1">
shifts gevuld
</p>
<VProgressLinear
:model-value="shiftFillPercent"
:color="shiftFillColor"
height="6"
rounded
/>
</div>
<p class="mb-1">
Shifts gevuld
</p>
<p class="mb-0">
<span class="text-heading fw-medium me-2">{{ shiftFillPercent }}%</span>
<span class="text-body-secondary text-sm">bezetting</span>
</p>
</VCardText>
</VCard>
</VCol>

View File

@@ -3,6 +3,7 @@ import { useOrganisationStore } from '@/stores/useOrganisationStore'
import PersonTagsTab from '@/components/organisation/PersonTagsTab.vue'
import RegistrationFieldTemplatesTab from '@/components/organisation/RegistrationFieldTemplatesTab.vue'
import CrowdTypesManager from '@/components/organisations/CrowdTypesManager.vue'
import EmailBrandingTab from '@/components/organisation/EmailBrandingTab.vue'
const route = useRoute()
const router = useRouter()
@@ -14,6 +15,7 @@ const tabs = [
{ value: 'tags', label: 'Tags & Vaardigheden', icon: 'tabler-tag' },
{ value: 'templates', label: 'Registratieveld-templates', icon: 'tabler-forms' },
{ value: 'crowd-types', label: 'Crowd types', icon: 'tabler-users-group' },
{ value: 'email-branding', label: 'E-mail opmaak', icon: 'tabler-mail' },
]
const activeTab = computed({
@@ -67,6 +69,9 @@ const activeTab = computed({
<VWindowItem value="crowd-types">
<CrowdTypesManager :org-id="orgId" />
</VWindowItem>
<VWindowItem value="email-branding">
<EmailBrandingTab :org-id="orgId" />
</VWindowItem>
</VWindow>
</div>
</template>

View File

@@ -4,6 +4,11 @@ export interface Organisation {
slug: string
billing_status: 'trial' | 'active' | 'suspended' | 'cancelled'
settings: Record<string, unknown> | null
email_logo_url: string | null
email_primary_color: string | null
email_reply_to: string | null
email_sender_name: string | null
email_footer_text: string | null
created_at: string
}
@@ -19,6 +24,11 @@ export interface UpdateOrganisationPayload {
slug?: string
billing_status?: Organisation['billing_status']
settings?: Record<string, unknown>
email_logo_url?: string | null
email_primary_color?: string | null
email_reply_to?: string | null
email_sender_name?: string | null
email_footer_text?: string | null
}
export interface CrowdType {

View File

@@ -1,47 +1,53 @@
import { breakpointsVuetifyV3 } from '@vueuse/core'
import { h } from 'vue'
import { VIcon } from 'vuetify/components/VIcon'
import { defineThemeConfig } from '@core'
import { Skins } from '@core/enums'
import { breakpointsVuetifyV3 } from "@vueuse/core";
import { h } from "vue";
import { VIcon } from "vuetify/components/VIcon";
import { defineThemeConfig } from "@core";
import { Skins } from "@core/enums";
import { AppContentLayoutNav, ContentWidth, FooterType, NavbarType } from '@layouts/enums'
import {
AppContentLayoutNav,
ContentWidth,
FooterType,
NavbarType,
} from "@layouts/enums";
export const { themeConfig, layoutConfig } = defineThemeConfig({
app: {
title: 'Crewli',
title: "Crewli",
logo: h(
'span',
"span",
{
class: 'crewli-mark text-h5 font-weight-bold',
style: 'line-height: 1.2; letter-spacing: -0.02em; color: rgb(var(--v-theme-primary));',
class: "crewli-mark text-h5 font-weight-bold",
style:
"line-height: 1.2; letter-spacing: -0.02em; color: rgb(var(--v-theme-primary));",
},
'C',
"C",
),
contentWidth: ContentWidth.Boxed,
contentLayoutNav: AppContentLayoutNav.Vertical,
overlayNavFromBreakpoint: breakpointsVuetifyV3.lg - 1, // 1 for matching with vuetify breakpoint. Docs: https://next.vuetifyjs.com/en/features/display-and-platform/
i18n: {
enable: false,
defaultLocale: 'en',
defaultLocale: "en",
langConfig: [
{
label: 'English',
i18nLang: 'en',
label: "English",
i18nLang: "en",
isRTL: false,
},
{
label: 'French',
i18nLang: 'fr',
label: "French",
i18nLang: "fr",
isRTL: false,
},
{
label: 'Arabic',
i18nLang: 'ar',
label: "Arabic",
i18nLang: "ar",
isRTL: true,
},
],
},
theme: 'system',
theme: "system",
skin: Skins.Default,
iconRenderer: VIcon,
},
@@ -52,12 +58,12 @@ export const { themeConfig, layoutConfig } = defineThemeConfig({
footer: { type: FooterType.Static },
verticalNav: {
isVerticalNavCollapsed: false,
defaultNavItemIconProps: { icon: 'tabler-circle' },
defaultNavItemIconProps: { icon: "tabler-circle" },
isVerticalNavSemiDark: true,
},
horizontalNav: {
type: 'sticky',
transition: 'slide-y-reverse-transition',
type: "sticky",
transition: "slide-y-reverse-transition",
popoverOffset: 6,
},
@@ -66,11 +72,11 @@ export const { themeConfig, layoutConfig } = defineThemeConfig({
// Such as: chevronDown: { icon: 'tabler-chevron-down', color:'primary', size: '24' },
*/
icons: {
chevronDown: { icon: 'tabler-chevron-down' },
chevronRight: { icon: 'tabler-chevron-right', size: 20 },
close: { icon: 'tabler-x', size: 20 },
verticalNavPinned: { icon: 'tabler-circle-dot', size: 20 },
verticalNavUnPinned: { icon: 'tabler-circle', size: 20 },
sectionTitlePlaceholder: { icon: 'tabler-minus' },
chevronDown: { icon: "tabler-chevron-down" },
chevronRight: { icon: "tabler-chevron-right", size: 20 },
close: { icon: "tabler-x", size: 20 },
verticalNavPinned: { icon: "tabler-circle-dot", size: 20 },
verticalNavUnPinned: { icon: "tabler-circle", size: 20 },
sectionTitlePlaceholder: { icon: "tabler-minus" },
},
})
});