Files
social-media/frontend/src/features/campaigns/views/CampaignsView.vue
Jonathan Bourdon 1ca6ab7117
All checks were successful
deploy-socialize / image (push) Successful in 50s
deploy-socialize / deploy (push) Successful in 19s
feat: centralize frontend Vuetify styling
2026-05-08 13:45:42 -04:00

364 lines
10 KiB
Vue

<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import { useCampaignsStore } from '@/features/campaigns/stores/campaignsStore.js';
const route = useRoute();
const workspaceStore = useWorkspaceStore();
const clientsStore = useClientsStore();
const campaignsStore = useCampaignsStore();
const { t } = useI18n();
const isCreateFormVisible = ref(false);
const formError = ref(null);
const form = reactive({
name: '',
startDate: '',
endDate: '',
description: '',
notes: '',
});
const operationalClient = computed(() => clientsStore.operationalClient);
function resetForm() {
form.name = '';
form.startDate = '';
form.endDate = '';
form.description = '';
form.notes = '';
formError.value = null;
}
function openCreateForm() {
resetForm();
isCreateFormVisible.value = true;
}
async function submitForm() {
if (campaignsStore.isCreating) {
return;
}
formError.value = null;
if (!form.name || !form.startDate || !form.endDate) {
formError.value = t('campaigns.errors.required');
return;
}
if (new Date(form.endDate) < new Date(form.startDate)) {
formError.value = t('campaigns.errors.invalidDateRange');
return;
}
if (!operationalClient.value?.id) {
formError.value = t('campaigns.errors.workspaceAccountRequired');
return;
}
try {
await campaignsStore.createCampaign({
clientId: operationalClient.value.id,
name: form.name,
startDate: new Date(form.startDate).toISOString(),
endDate: new Date(form.endDate).toISOString(),
description: form.description,
notes: form.notes,
});
isCreateFormVisible.value = false;
resetForm();
} catch (error) {
formError.value = t('campaigns.errors.createFailed');
}
}
watch(
() => route.query.create,
createValue => {
if (createValue === 'true') {
openCreateForm();
}
},
{ immediate: true }
);
function formatCampaignDateRange(campaign) {
if (!campaign?.startDate || !campaign?.endDate) {
return t('campaigns.noDateRange');
}
const start = new Date(campaign.startDate);
const end = new Date(campaign.endDate);
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
}).formatRange(start, end);
}
</script>
<template>
<section class="page-shell">
<div class="header">
<div>
<div class="eyebrow">{{ t('campaigns.eyebrow') }}</div>
<h1>{{ t('campaigns.title') }}</h1>
<p>{{ t('campaigns.description') }}</p>
</div>
</div>
<div
v-if="isCreateFormVisible"
class="create-panel"
>
<div class="panel-header">
<strong>{{ t('campaigns.createTitle') }}</strong>
<span>{{ workspaceStore.activeWorkspace?.name }}</span>
</div>
<div
v-if="formError"
class="page-message error"
>
{{ formError }}
</div>
<div class="form-grid">
<v-text-field
v-model="form.startDate"
:label="t('campaigns.fields.startDate')"
:disabled="campaignsStore.isCreating"
type="date"
variant="outlined"
hide-details
/>
<v-text-field
v-model="form.endDate"
:label="t('campaigns.fields.endDate')"
:disabled="campaignsStore.isCreating"
type="date"
variant="outlined"
hide-details
/>
<v-text-field
v-model="form.name"
class="field-wide"
:label="t('campaigns.fields.name')"
:disabled="campaignsStore.isCreating"
variant="outlined"
hide-details
/>
<v-textarea
v-model="form.description"
class="field-wide"
:label="t('campaigns.fields.description')"
:disabled="campaignsStore.isCreating"
rows="3"
variant="outlined"
hide-details
/>
<v-textarea
v-model="form.notes"
class="field-wide"
:label="t('campaigns.fields.notes')"
:disabled="campaignsStore.isCreating"
rows="3"
variant="outlined"
hide-details
/>
</div>
<div class="panel-actions">
<v-btn variant="text" :ripple="false"
class="secondary"
:disabled="campaignsStore.isCreating"
@click="isCreateFormVisible = false"
>
{{ t('common.cancel') }}
</v-btn>
<v-btn variant="text" :ripple="false"
class="primary"
:disabled="campaignsStore.isCreating"
@click="submitForm"
>
<v-progress-circular
v-if="campaignsStore.isCreating"
indeterminate
:size="16"
:width="2"
/>
<span>{{ campaignsStore.isCreating ? t('common.creating') : t('campaigns.createTitle') }}</span>
</v-btn>
</div>
</div>
<div
v-if="campaignsStore.isLoading"
class="page-message"
>
{{ t('campaigns.loading') }}
</div>
<div
v-else-if="campaignsStore.error"
class="page-message error"
>
{{ campaignsStore.error }}
</div>
<div class="campaign-stack">
<router-link
v-for="campaign in campaignsStore.campaigns"
:key="campaign.id"
:to="{ name: 'campaign-detail', params: { campaignId: campaign.id } }"
class="campaign-row"
>
<div>
<strong>{{ campaign.name }}</strong>
<span>{{ campaign.description || campaign.status }}</span>
</div>
<div class="campaign-meta">
<span>{{ workspaceStore.activeWorkspace?.name || t('nav.noWorkspace') }}</span>
<em>{{ formatCampaignDateRange(campaign) }}</em>
</div>
</router-link>
</div>
<div
v-if="!campaignsStore.isLoading && !campaignsStore.campaigns.length"
class="page-message"
>
{{ t('campaigns.empty') }}
</div>
</section>
</template>
<style scoped>
@reference "@/assets/main.css";
.page-shell {
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
}
.eyebrow {
@apply text-xs font-bold uppercase tracking-[0.24em];
color: var(--app-color-on-tertiary);
}
.header h1 {
@apply mt-2 text-4xl font-black;
color: var(--app-color-on-surface);
}
.header p,
.panel-header span,
.campaign-row span,
.campaign-meta span,
.campaign-meta em {
@apply text-sm leading-6 not-italic;
color: var(--app-text-muted);
}
.primary,
.secondary {
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-bold transition;
}
.primary {
background: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.secondary {
background: var(--app-control-hover);
color: var(--app-color-on-surface);
}
.create-panel,
.campaign-row {
@apply rounded-[1.5rem] border;
background: rgba(255, 255, 255, 0.9);
border-color: var(--app-border-subtle);
}
.create-panel {
@apply flex flex-col gap-5 p-5;
}
.panel-header {
@apply flex flex-col gap-2;
}
.panel-header strong,
.campaign-row strong {
color: var(--app-color-on-surface);
}
.form-grid {
@apply grid gap-4 md:grid-cols-2;
}
.field {
@apply flex flex-col gap-2 text-sm font-semibold;
color: var(--app-color-on-surface);
}
.field-wide {
@apply md:col-span-2;
}
.field input {
@apply rounded-2xl border px-4 py-3 text-sm;
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.95);
color: var(--app-color-on-surface);
}
.field textarea {
@apply min-h-28 rounded-2xl border px-4 py-3 text-sm;
border-color: var(--app-border-subtle);
background: rgba(255, 255, 255, 0.95);
color: var(--app-color-on-surface);
resize: vertical;
}
.panel-actions {
@apply flex justify-end gap-3;
}
.campaign-stack {
@apply flex flex-col gap-4;
}
.campaign-row {
@apply flex flex-col justify-between gap-4 p-5 no-underline lg:flex-row lg:items-center;
}
.campaign-row strong {
@apply block text-xl font-black;
}
.campaign-meta {
@apply flex flex-col items-start gap-1 lg:items-end;
}
.page-message {
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
background: rgba(255, 255, 255, 0.84);
border-color: var(--app-border-subtle);
color: var(--app-text-muted);
}
.page-message.error {
color: var(--app-danger-muted);
}
</style>