feat: cms feedback and customer management
This commit is contained in:
3
.npmrc
3
.npmrc
@@ -2,3 +2,6 @@
|
||||
registry=https://npm.infra.mintel.me/
|
||||
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
||||
always-auth=true
|
||||
|
||||
public-hoist-pattern[]=*
|
||||
shamefully-hoist=true
|
||||
|
||||
8450
commit_error.log
Normal file
8450
commit_error.log
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,22 @@
|
||||
import baseConfig from "@mintel/eslint-config";
|
||||
import { nextConfig } from "@mintel/eslint-config/next";
|
||||
|
||||
export default nextConfig;
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"packages/cms-infra/extensions/**",
|
||||
"packages/customer-manager/index.js",
|
||||
"**/*.db",
|
||||
"**/build/**",
|
||||
],
|
||||
},
|
||||
...baseConfig,
|
||||
...nextConfig.map((config) => ({
|
||||
...config,
|
||||
files: [
|
||||
"apps/sample-website/**/*.{ts,tsx}",
|
||||
"packages/gatekeeper/**/*.{ts,tsx}",
|
||||
"../klz-2026/**/*.{ts,tsx}",
|
||||
],
|
||||
})),
|
||||
];
|
||||
|
||||
14
eslint_report.txt
Normal file
14
eslint_report.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
Pages directory cannot be found at /Users/marcmintel/Projects/at-mintel/pages or /Users/marcmintel/Projects/at-mintel/src/pages. If using a custom path, please configure with the `no-html-link-for-pages` rule in your eslint config file.
|
||||
|
||||
/Users/marcmintel/Projects/at-mintel/packages/cli/src/index.ts
|
||||
32:16 warning 'e' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
32:19 error Empty block statement no-empty
|
||||
|
||||
/Users/marcmintel/Projects/at-mintel/packages/cms-infra/extensions/customer-manager/index.js
|
||||
0:0 warning File ignored because of a matching ignore pattern. Use "--no-ignore" to disable file ignore settings or use "--no-warn-ignored" to suppress this warning
|
||||
|
||||
/Users/marcmintel/Projects/at-mintel/packages/customer-manager/index.js
|
||||
0:0 warning File ignored because of a matching ignore pattern. Use "--no-ignore" to disable file ignore settings or use "--no-warn-ignored" to suppress this warning
|
||||
|
||||
✖ 4 problems (1 error, 3 warnings)
|
||||
|
||||
@@ -39,5 +39,11 @@
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"import-in-the-middle": "^3.0.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"require-in-the-middle": "^8.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,24 +25,25 @@ program
|
||||
console.log(chalk.cyan("Running Next.js locally..."));
|
||||
execSync("next dev", { stdio: "inherit" });
|
||||
} else {
|
||||
console.log(chalk.cyan("Starting Docker stack (App, Directus, DB)..."));
|
||||
// Ensure network exists
|
||||
try {
|
||||
execSync("docker network create infra", { stdio: "ignore" });
|
||||
} catch (e) {}
|
||||
console.log(chalk.cyan("Starting Docker stack (App, Directus, DB)..."));
|
||||
// Ensure network exists
|
||||
} catch (_e) {
|
||||
// Network already exists or docker is not running
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(`
|
||||
console.log(
|
||||
chalk.yellow(`
|
||||
📱 App: http://localhost:3000
|
||||
🗄️ CMS: http://localhost:8055/admin
|
||||
🚦 Traefik: http://localhost:8080
|
||||
`),
|
||||
);
|
||||
execSync(
|
||||
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
}
|
||||
);
|
||||
execSync(
|
||||
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
const directus = program
|
||||
@@ -60,6 +61,115 @@ directus
|
||||
});
|
||||
});
|
||||
|
||||
directus
|
||||
.command("bootstrap-feedback")
|
||||
.description("Setup Directus collections and flows for Feedback")
|
||||
.action(async () => {
|
||||
const { execSync } = await import("child_process");
|
||||
console.log(chalk.blue("📧 Bootstrapping Visual Feedback System..."));
|
||||
// Use the logic from setup-feedback-hardened.ts
|
||||
const bootstrapScript = `
|
||||
import { createDirectus, rest, authentication, createCollection, createDashboard, createPanel, createItems, createPermission, readPolicies, readRoles, readUsers } from '@directus/sdk';
|
||||
|
||||
async function setup() {
|
||||
const url = process.env.DIRECTUS_URL || 'http://localhost:8055';
|
||||
const email = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||
const password = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||
|
||||
if (!email || !password) {
|
||||
console.error('❌ DIRECTUS_ADMIN_EMAIL or DIRECTUS_ADMIN_PASSWORD not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = createDirectus(url).with(authentication('json')).with(rest());
|
||||
|
||||
try {
|
||||
console.log('🔑 Authenticating...');
|
||||
await client.login(email, password);
|
||||
|
||||
const roles = await client.request(readRoles());
|
||||
const adminRole = roles.find(r => r.name === 'Administrator');
|
||||
const policies = await client.request(readPolicies());
|
||||
const adminPolicy = policies.find(p => p.name === 'Administrator');
|
||||
|
||||
console.log('🏗️ Creating Collection "visual_feedback"...');
|
||||
try {
|
||||
await client.request(createCollection({
|
||||
collection: 'visual_feedback',
|
||||
meta: { icon: 'feedback', display_template: '{{user_name}}: {{text}}' },
|
||||
fields: [
|
||||
{ field: 'id', type: 'uuid', schema: { is_primary_key: true } },
|
||||
{ field: 'status', type: 'string', schema: { default_value: 'open' }, meta: { interface: 'select-dropdown' } },
|
||||
{ field: 'url', type: 'string' },
|
||||
{ field: 'selector', type: 'string' },
|
||||
{ field: 'x', type: 'float' },
|
||||
{ field: 'y', type: 'float' },
|
||||
{ field: 'type', type: 'string' },
|
||||
{ field: 'text', type: 'text' },
|
||||
{ field: 'user_name', type: 'string' },
|
||||
{ field: 'user_identity', type: 'string' },
|
||||
{ field: 'screenshot', type: 'uuid', meta: { interface: 'file' } },
|
||||
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
|
||||
]
|
||||
} as any));
|
||||
} catch (_e) { console.log(' (Collection might already exist)'); }
|
||||
|
||||
try {
|
||||
await client.request(createCollection({
|
||||
collection: 'visual_feedback_comments',
|
||||
meta: { icon: 'comment' },
|
||||
fields: [
|
||||
{ field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true } },
|
||||
{ field: 'feedback_id', type: 'uuid', meta: { interface: 'select-dropdown' } },
|
||||
{ field: 'user_name', type: 'string' },
|
||||
{ field: 'text', type: 'text' },
|
||||
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
|
||||
]
|
||||
} as any));
|
||||
} catch (e) { }
|
||||
|
||||
if (adminPolicy) {
|
||||
console.log('🔐 Granting ALL permissions to Administrator Policy...');
|
||||
for (const coll of ['visual_feedback', 'visual_feedback_comments']) {
|
||||
for (const action of ['create', 'read', 'update', 'delete']) {
|
||||
try {
|
||||
await client.request(createPermission({
|
||||
collection: coll,
|
||||
action,
|
||||
fields: ['*'],
|
||||
policy: adminPolicy.id
|
||||
} as any));
|
||||
} catch (_e) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📊 Creating Dashboard...');
|
||||
try {
|
||||
const dash = await client.request(createDashboard({ name: 'Visual Feedback', icon: 'feedback', color: '#6366f1' }));
|
||||
await client.request(createPanel({
|
||||
dashboard: dash.id,
|
||||
name: 'Total Feedbacks',
|
||||
type: 'metric',
|
||||
width: 12, height: 6, position_x: 1, position_y: 1,
|
||||
options: { collection: 'visual_feedback', function: 'count', field: 'id' }
|
||||
} as any));
|
||||
} catch (e) { }
|
||||
|
||||
console.log('✨ FEEDBACK BOOTSTRAP DONE.');
|
||||
} catch (e) { console.error('❌ FAILURE:', e); }
|
||||
}
|
||||
setup();
|
||||
`;
|
||||
const tempFile = path.join(process.cwd(), "temp-bootstrap-feedback.ts");
|
||||
await fs.writeFile(tempFile, bootstrapScript);
|
||||
try {
|
||||
execSync("npx tsx --env-file=.env " + tempFile, { stdio: "inherit" });
|
||||
} finally {
|
||||
await fs.remove(tempFile);
|
||||
}
|
||||
});
|
||||
|
||||
directus
|
||||
.command("sync <action> <env>")
|
||||
.description("Sync Directus data (push/pull) for a specific environment")
|
||||
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
<private-view title="Customer Manager">
|
||||
<template #navigation>
|
||||
<v-list nav>
|
||||
<v-list-item @click="drawerCompanyActive = true" clickable>
|
||||
<v-list-item @click="openCreateCompany" clickable>
|
||||
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow text="Neue Firma anlegen" />
|
||||
@@ -37,7 +37,7 @@
|
||||
<div v-if="!selectedCompany" class="empty-state">
|
||||
<v-info title="Firmen auswählen" icon="business" center>
|
||||
Wähle eine Firma in der Navigation aus oder
|
||||
<v-button x-small @click="drawerCompanyActive = true">erstelle eine neue Firma</v-button>.
|
||||
<v-button x-small @click="openCreateCompany">erstelle eine neue Firma</v-button>.
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
@@ -48,10 +48,10 @@
|
||||
<p class="subtitle">{{ employees.length }} Kunden-Mitarbeiter</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openCompanyDetails">
|
||||
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditCompany">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
<v-button primary @click="drawerEmployeeActive = true">
|
||||
<v-button primary @click="openCreateEmployee">
|
||||
Mitarbeiter hinzufügen
|
||||
</v-button>
|
||||
</div>
|
||||
@@ -78,113 +78,79 @@
|
||||
</span>
|
||||
<v-chip v-else x-small>Noch nie</v-chip>
|
||||
</template>
|
||||
|
||||
<template #[`item.actions`]="{ item }">
|
||||
<div class="action-buttons">
|
||||
<v-button
|
||||
v-tooltip.bottom="'Quick-View'"
|
||||
secondary
|
||||
rounded
|
||||
icon
|
||||
@click.stop="openQuickView(item)"
|
||||
>
|
||||
<v-icon name="visibility" />
|
||||
</v-button>
|
||||
</div>
|
||||
</template>
|
||||
</v-table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Drawer: Create Company -->
|
||||
<!-- Drawer: Company Form -->
|
||||
<v-drawer
|
||||
v-model="drawerCompanyActive"
|
||||
title="Neue Firma anlegen"
|
||||
:title="isEditingCompany ? 'Firma bearbeiten' : 'Neue Firma anlegen'"
|
||||
icon="business"
|
||||
@cancel="drawerCompanyActive = false"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<v-field-template label="Firmenname" required>
|
||||
<v-input v-model="newCompany.name" placeholder="z.B. KLZ Cables" autofocus />
|
||||
</v-field-template>
|
||||
<div v-if="drawerCompanyActive" class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Firmenname</span>
|
||||
<v-input v-model="companyForm.name" placeholder="z.B. KLZ Cables" autofocus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="saving" @click="createCompany">Firma speichern</v-button>
|
||||
<v-button primary block :loading="saving" @click="saveCompany">Speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
|
||||
<!-- Drawer: Create Employee -->
|
||||
<!-- Drawer: Employee Form -->
|
||||
<v-drawer
|
||||
v-model="drawerEmployeeActive"
|
||||
title="Neuen Mitarbeiter anlegen"
|
||||
icon="person_add"
|
||||
:title="isEditingEmployee ? 'Mitarbeiter bearbeiten' : 'Neuen Mitarbeiter anlegen'"
|
||||
icon="person"
|
||||
@cancel="drawerEmployeeActive = false"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<v-field-template label="Vorname" required>
|
||||
<v-input v-model="newEmployee.first_name" placeholder="Vorname" autofocus />
|
||||
</v-field-template>
|
||||
<v-field-template label="Nachname" required>
|
||||
<v-input v-model="newEmployee.last_name" placeholder="Nachname" />
|
||||
</v-field-template>
|
||||
<v-field-template label="E-Mail" required>
|
||||
<v-input v-model="newEmployee.email" placeholder="E-Mail Adresse" type="email" />
|
||||
</v-field-template>
|
||||
<div v-if="drawerEmployeeActive" class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Vorname</span>
|
||||
<v-input v-model="employeeForm.first_name" placeholder="Vorname" autofocus />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Nachname</span>
|
||||
<v-input v-model="employeeForm.last_name" placeholder="Nachname" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">E-Mail</span>
|
||||
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
|
||||
</div>
|
||||
|
||||
<v-divider v-if="isEditingEmployee" />
|
||||
|
||||
<div v-if="isEditingEmployee" class="field">
|
||||
<span class="label">Temporäres Passwort</span>
|
||||
<v-input v-model="employeeForm.temporary_password" readonly class="password-input" />
|
||||
<p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="saving" @click="createEmployee">Mitarbeiter speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
<v-button primary block :loading="saving" @click="saveEmployee">Daten speichern</v-button>
|
||||
|
||||
<!-- Drawer: Employee Detail Quick-View -->
|
||||
<v-drawer
|
||||
v-model="drawerDetailActive"
|
||||
:title="drawerItem ? `${drawerItem.first_name} ${drawerItem.last_name}` : 'Mitarbeiter'"
|
||||
icon="person"
|
||||
@cancel="drawerDetailActive = false"
|
||||
>
|
||||
<div v-if="drawerItem" class="drawer-content">
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<v-list-item-content>
|
||||
<v-list-item-label>Name</v-list-item-label>
|
||||
<v-list-item-hint>{{ drawerItem.first_name }} {{ drawerItem.last_name }}</v-list-item-hint>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-item divider>
|
||||
<v-list-item-content>
|
||||
<v-list-item-label>E-Mail</v-list-item-label>
|
||||
<v-list-item-hint>{{ drawerItem.email }}</v-list-item-hint>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-item divider>
|
||||
<v-list-item-content>
|
||||
<v-list-item-label>Temporäres Passwort</v-list-item-label>
|
||||
<v-list-item-hint class="password-text">{{ drawerItem.temporary_password || 'Noch nicht generiert' }}</v-list-item-hint>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<v-list-item divider>
|
||||
<v-list-item-content>
|
||||
<v-list-item-label>Zuletzt eingeladen</v-list-item-label>
|
||||
<v-list-item-hint>{{ drawerItem.last_invited ? formatDate(drawerItem.last_invited) : 'Nie' }}</v-list-item-hint>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button
|
||||
primary
|
||||
block
|
||||
:loading="invitingId === drawerItem.id"
|
||||
@click="inviteUser(drawerItem)"
|
||||
>
|
||||
Zugangsdaten generieren & senden
|
||||
</v-button>
|
||||
<v-button secondary block @click="openFullProfile(drawerItem)">
|
||||
Vollständiges Profil öffnen
|
||||
</v-button>
|
||||
<template v-if="isEditingEmployee">
|
||||
<v-divider />
|
||||
<v-button
|
||||
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
|
||||
secondary
|
||||
block
|
||||
:loading="invitingId === employeeForm.id"
|
||||
@click="inviteUser(employeeForm)"
|
||||
>
|
||||
<v-icon name="send" left /> Zugangsdaten senden
|
||||
</v-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
@@ -192,7 +158,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const api = useApi();
|
||||
@@ -205,21 +171,25 @@ const saving = ref(false);
|
||||
const invitingId = ref<string | null>(null);
|
||||
const notice = ref<{ type: string; message: string } | null>(null);
|
||||
|
||||
// Drawers
|
||||
// Forms State
|
||||
const drawerCompanyActive = ref(false);
|
||||
const drawerEmployeeActive = ref(false);
|
||||
const drawerDetailActive = ref(false);
|
||||
const drawerItem = ref<any>(null);
|
||||
const isEditingCompany = ref(false);
|
||||
const companyForm = ref({ id: '', name: '' });
|
||||
|
||||
// Forms
|
||||
const newCompany = ref({ name: '' });
|
||||
const newEmployee = ref({ first_name: '', last_name: '', email: '' });
|
||||
const drawerEmployeeActive = ref(false);
|
||||
const isEditingEmployee = ref(false);
|
||||
const employeeForm = ref({
|
||||
id: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
temporary_password: ''
|
||||
});
|
||||
|
||||
const tableHeaders = [
|
||||
{ text: 'Name', value: 'name', sortable: true },
|
||||
{ text: 'E-Mail', value: 'email', sortable: true },
|
||||
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true },
|
||||
{ text: '', value: 'actions', width: 60, align: 'right' },
|
||||
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
|
||||
];
|
||||
|
||||
async function fetchCompanies() {
|
||||
@@ -238,11 +208,7 @@ async function selectCompany(company: any) {
|
||||
try {
|
||||
const res = await api.get('/items/client_users', {
|
||||
params: {
|
||||
filter: {
|
||||
company: {
|
||||
_eq: company.id,
|
||||
},
|
||||
},
|
||||
filter: { company: { _eq: company.id } },
|
||||
fields: ['*'],
|
||||
sort: 'first_name',
|
||||
},
|
||||
@@ -253,15 +219,40 @@ async function selectCompany(company: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createCompany() {
|
||||
if (!newCompany.value.name) return;
|
||||
// Company Actions
|
||||
function openCreateCompany() {
|
||||
isEditingCompany.value = false;
|
||||
companyForm.value = { id: '', name: '' };
|
||||
drawerCompanyActive.value = true;
|
||||
}
|
||||
|
||||
async function openEditCompany() {
|
||||
if (!selectedCompany.value) return;
|
||||
companyForm.value = {
|
||||
id: selectedCompany.value.id,
|
||||
name: selectedCompany.value.name
|
||||
};
|
||||
isEditingCompany.value = true;
|
||||
await nextTick();
|
||||
drawerCompanyActive.value = true;
|
||||
}
|
||||
|
||||
async function saveCompany() {
|
||||
if (!companyForm.value.name) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.post('/items/companies', newCompany.value);
|
||||
notice.value = { type: 'success', message: 'Firma erfolgreich angelegt!' };
|
||||
newCompany.value.name = '';
|
||||
if (isEditingCompany.value) {
|
||||
await api.patch(`/items/companies/${companyForm.value.id}`, { name: companyForm.value.name });
|
||||
notice.value = { type: 'success', message: 'Firma aktualisiert!' };
|
||||
} else {
|
||||
await api.post('/items/companies', { name: companyForm.value.name });
|
||||
notice.value = { type: 'success', message: 'Firma angelegt!' };
|
||||
}
|
||||
drawerCompanyActive.value = false;
|
||||
await fetchCompanies();
|
||||
if (selectedCompany.value?.id === companyForm.value.id) {
|
||||
selectedCompany.value.name = companyForm.value.name;
|
||||
}
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: e.message };
|
||||
} finally {
|
||||
@@ -269,16 +260,46 @@ async function createCompany() {
|
||||
}
|
||||
}
|
||||
|
||||
async function createEmployee() {
|
||||
if (!newEmployee.value.email || !selectedCompany.value) return;
|
||||
// Employee Actions
|
||||
function openCreateEmployee() {
|
||||
isEditingEmployee.value = false;
|
||||
employeeForm.value = { id: '', first_name: '', last_name: '', email: '', temporary_password: '' };
|
||||
drawerEmployeeActive.value = true;
|
||||
}
|
||||
|
||||
async function openEditEmployee(item: any) {
|
||||
employeeForm.value = {
|
||||
id: item.id || '',
|
||||
first_name: item.first_name || '',
|
||||
last_name: item.last_name || '',
|
||||
email: item.email || '',
|
||||
temporary_password: item.temporary_password || ''
|
||||
};
|
||||
isEditingEmployee.value = true;
|
||||
await nextTick();
|
||||
drawerEmployeeActive.value = true;
|
||||
}
|
||||
|
||||
async function saveEmployee() {
|
||||
if (!employeeForm.value.email || !selectedCompany.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await api.post('/items/client_users', {
|
||||
...newEmployee.value,
|
||||
company: selectedCompany.value.id
|
||||
});
|
||||
notice.value = { type: 'success', message: 'Mitarbeiter erfolgreich angelegt!' };
|
||||
newEmployee.value = { first_name: '', last_name: '', email: '' };
|
||||
if (isEditingEmployee.value) {
|
||||
await api.patch(`/items/client_users/${employeeForm.value.id}`, {
|
||||
first_name: employeeForm.value.first_name,
|
||||
last_name: employeeForm.value.last_name,
|
||||
email: employeeForm.value.email
|
||||
});
|
||||
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
|
||||
} else {
|
||||
await api.post('/items/client_users', {
|
||||
first_name: employeeForm.value.first_name,
|
||||
last_name: employeeForm.value.last_name,
|
||||
email: employeeForm.value.email,
|
||||
company: selectedCompany.value.id
|
||||
});
|
||||
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
|
||||
}
|
||||
drawerEmployeeActive.value = false;
|
||||
await selectCompany(selectedCompany.value);
|
||||
} catch (e: any) {
|
||||
@@ -291,61 +312,34 @@ async function createEmployee() {
|
||||
async function inviteUser(user: any) {
|
||||
invitingId.value = user.id;
|
||||
try {
|
||||
// Flow ID from Step 2450
|
||||
await api.post(`/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501`, [user.id]);
|
||||
|
||||
notice.value = {
|
||||
type: 'success',
|
||||
message: `Zugangsdaten für ${user.first_name} wurden versendet. 📧`
|
||||
};
|
||||
|
||||
notice.value = { type: 'success', message: `Zugangsdaten für ${user.first_name} versendet. 📧` };
|
||||
await selectCompany(selectedCompany.value);
|
||||
if (drawerItem.value?.id === user.id) {
|
||||
drawerItem.value = employees.value.find(e => e.id === user.id);
|
||||
}
|
||||
if (drawerEmployeeActive.value && employeeForm.value.id === user.id) {
|
||||
const updated = employees.value.find(e => e.id === user.id);
|
||||
if (updated) {
|
||||
employeeForm.value.temporary_password = updated.temporary_password;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Failed to trigger invite flow', e);
|
||||
notice.value = {
|
||||
type: 'danger',
|
||||
message: `Fehler beim Senden: ${e.response?.data?.errors?.[0]?.message || e.message}`
|
||||
};
|
||||
notice.value = { type: 'danger', message: `Fehler: ${e.message}` };
|
||||
} finally {
|
||||
invitingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function onRowClick(event: any) {
|
||||
const item = event?.item || event;
|
||||
if (item && item.id) {
|
||||
openQuickView(item);
|
||||
openEditEmployee(item);
|
||||
}
|
||||
}
|
||||
|
||||
function openQuickView(item: any) {
|
||||
drawerItem.value = item;
|
||||
drawerDetailActive.value = true;
|
||||
}
|
||||
|
||||
function openFullProfile(item: any) {
|
||||
if (item && item.id) {
|
||||
window.open(`/admin/content/client_users/${item.id}`, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
function openCompanyDetails() {
|
||||
if (selectedCompany.value) {
|
||||
window.open(`/admin/content/companies/${selectedCompany.value.id}`, '_blank');
|
||||
}
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -354,101 +348,30 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-wrapper {
|
||||
padding: 32px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.company-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--theme--foreground-subdued);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-date {
|
||||
font-size: 12px;
|
||||
color: var(--theme--foreground-subdued);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.drawer-actions {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.password-text {
|
||||
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; }
|
||||
.company-item { cursor: pointer; }
|
||||
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
|
||||
.header-right { display: flex; gap: 12px; }
|
||||
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; }
|
||||
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; }
|
||||
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
.user-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.user-name { font-weight: 600; }
|
||||
.status-date { font-size: 12px; color: var(--theme--foreground-subdued); }
|
||||
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
|
||||
.form-section { display: flex; flex-direction: column; gap: 20px; }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
|
||||
.field-note { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px; }
|
||||
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.password-input :deep(textarea) {
|
||||
font-family: var(--family-monospace);
|
||||
background: var(--theme--background-subdued);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
color: var(--theme--primary);
|
||||
font-weight: 800;
|
||||
color: var(--theme--primary) !important;
|
||||
background: var(--theme--background-subdued) !important;
|
||||
}
|
||||
|
||||
.clickable-table :deep(tbody tr) {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.clickable-table :deep(tbody tr:hover) {
|
||||
background-color: var(--theme--background-subdued) !important;
|
||||
}
|
||||
|
||||
:deep(.v-list-item) {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
.clickable-table :deep(tbody tr) { cursor: pointer; transition: background-color 0.2s ease; }
|
||||
.clickable-table :deep(tbody tr:hover) { background-color: var(--theme--background-subdued) !important; }
|
||||
:deep(.v-list-item) { cursor: pointer !important; }
|
||||
</style>
|
||||
|
||||
@@ -3,13 +3,21 @@ import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/.next/**"],
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/.next/**", "**/build/**"],
|
||||
},
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
rules: {
|
||||
"no-unused-vars": "warn",
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
"no-console": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.1.6",
|
||||
"next": "15.1.7",
|
||||
"next-intl": "^4.8.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global module */
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
|
||||
@@ -49,13 +49,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
try {
|
||||
const clientUsersRes = await fetch(
|
||||
`${process.env.INFRA_DIRECTUS_URL}/items/client_users?filter[email][_eq]=${encodeURIComponent(
|
||||
email
|
||||
email,
|
||||
)}&fields=*,company.*`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.INFRA_DIRECTUS_TOKEN}`,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (clientUsersRes.ok) {
|
||||
@@ -63,7 +63,11 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const clientUser = users[0];
|
||||
|
||||
// ⚠️ NOTE: Plain text check for demo/dev, should use argon2 in production
|
||||
if (clientUser && clientUser.password === password) {
|
||||
if (
|
||||
clientUser &&
|
||||
(clientUser.password === password ||
|
||||
clientUser.temporary_password === password)
|
||||
) {
|
||||
userIdentity = clientUser.first_name || clientUser.email;
|
||||
userCompany = {
|
||||
id: clientUser.company?.id,
|
||||
@@ -94,7 +98,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
`${process.env.DIRECTUS_URL}/users/me?fields=*,company.*`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (userRes.ok) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global module, require */
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
@@ -55,5 +56,6 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "path";
|
||||
/* global process */
|
||||
import path from "node:path";
|
||||
|
||||
const buildLintCommand = (filenames) => {
|
||||
const isNext =
|
||||
@@ -11,7 +12,7 @@ const buildLintCommand = (filenames) => {
|
||||
.join(" --file ")}`;
|
||||
}
|
||||
|
||||
return "eslint --fix";
|
||||
return "eslint --fix --no-warn-ignored";
|
||||
};
|
||||
|
||||
const config = {
|
||||
|
||||
@@ -14,11 +14,7 @@ export interface BaseLayoutProps {
|
||||
brandColor?: string;
|
||||
}
|
||||
|
||||
export const BaseLayout = ({
|
||||
preview,
|
||||
children,
|
||||
brandColor = "#82ed20",
|
||||
}: BaseLayoutProps) => {
|
||||
export const BaseLayout = ({ preview, children }: BaseLayoutProps) => {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface MintelLayoutProps {
|
||||
|
||||
export const MintelLayout = ({ preview, children }: MintelLayoutProps) => {
|
||||
return (
|
||||
<BaseLayout preview={preview} brandColor="#82ed20">
|
||||
<BaseLayout preview={preview}>
|
||||
<Section style={header}>
|
||||
<MintelLogo />
|
||||
</Section>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global process, URL */
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import fs from "node:fs";
|
||||
|
||||
52
packages/next-feedback/package.json
Normal file
52
packages/next-feedback/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@mintel/next-feedback",
|
||||
"version": "1.0.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"./FeedbackOverlay": {
|
||||
"types": "./dist/components/FeedbackOverlay.d.ts",
|
||||
"import": "./dist/components/FeedbackOverlay.mjs",
|
||||
"require": "./dist/components/FeedbackOverlay.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^21.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.5.4",
|
||||
"html2canvas": "^1.4.1",
|
||||
"lucide-react": "^0.441.0",
|
||||
"next": "15.1.7",
|
||||
"tailwind-merge": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.17.16",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.2",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
}
|
||||
621
packages/next-feedback/src/components/FeedbackOverlay.tsx
Normal file
621
packages/next-feedback/src/components/FeedbackOverlay.tsx
Normal file
@@ -0,0 +1,621 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MessageSquare, X, Check, Plus, List, Send, User } from "lucide-react";
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import html2canvas from "html2canvas";
|
||||
|
||||
function cn(...inputs: any[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
interface FeedbackComment {
|
||||
id: string;
|
||||
userName: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Feedback {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
selector: string;
|
||||
text: string;
|
||||
type: "design" | "content";
|
||||
elementRect: DOMRect | null;
|
||||
userName: string;
|
||||
comments: FeedbackComment[];
|
||||
}
|
||||
|
||||
export function FeedbackOverlay() {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(
|
||||
null,
|
||||
);
|
||||
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
||||
const [currentComment, setCurrentComment] = useState("");
|
||||
const [currentType, setCurrentType] = useState<"design" | "content">(
|
||||
"design",
|
||||
);
|
||||
const [showList, setShowList] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<{
|
||||
identity: string;
|
||||
isDevFallback?: boolean;
|
||||
} | null>(null);
|
||||
const [newCommentTexts, setNewCommentTexts] = useState<{
|
||||
[feedbackId: string]: string;
|
||||
}>({});
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
|
||||
// 1. Fetch Identity and Existing Feedback
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const bypass = urlParams.get("gatekeeper_bypass");
|
||||
const apiUrl = bypass
|
||||
? `/api/whoami?gatekeeper_bypass=${bypass}`
|
||||
: "/api/whoami";
|
||||
|
||||
const res = await fetch(apiUrl);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCurrentUser(data);
|
||||
} else {
|
||||
setCurrentUser({ identity: "Guest" });
|
||||
}
|
||||
} catch (_e) {
|
||||
setCurrentUser({ identity: "Guest" });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFeedback = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/feedback");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const mapped = data.map((fb: any) => ({
|
||||
id: fb.id,
|
||||
x: fb.x,
|
||||
y: fb.y,
|
||||
selector: fb.selector,
|
||||
text: fb.text,
|
||||
type: fb.type,
|
||||
userName: fb.user_name,
|
||||
comments: (fb.comments || []).map((c: any) => ({
|
||||
id: c.id,
|
||||
userName: c.user_name,
|
||||
text: c.text,
|
||||
createdAt: c.date_created,
|
||||
})),
|
||||
}));
|
||||
setFeedbacks(mapped);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch feedbacks", e);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
fetchFeedback();
|
||||
}, []);
|
||||
|
||||
const getSelector = (el: HTMLElement): string => {
|
||||
if (el.id) return `#${el.id}`;
|
||||
const path = [];
|
||||
let curr: HTMLElement | null = el;
|
||||
while (curr && curr.parentElement) {
|
||||
const index = Array.from(curr.parentElement.children).indexOf(curr) + 1;
|
||||
path.unshift(`${curr.tagName.toLowerCase()}:nth-child(${index})`);
|
||||
curr = curr.parentElement;
|
||||
}
|
||||
return path.join(" > ");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setHoveredElement(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (selectedElement) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".feedback-ui-ignore")) {
|
||||
setHoveredElement(null);
|
||||
return;
|
||||
}
|
||||
setHoveredElement(target);
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (selectedElement) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".feedback-ui-ignore")) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setSelectedElement(target);
|
||||
setHoveredElement(null);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("click", handleClick, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("click", handleClick, true);
|
||||
};
|
||||
}, [isActive, selectedElement]);
|
||||
|
||||
const captureScreenshot = async (): Promise<string | null> => {
|
||||
try {
|
||||
setIsCapturing(true);
|
||||
const canvas = await html2canvas(document.body, {
|
||||
useCORS: true,
|
||||
scale: 1,
|
||||
ignoreElements: (el) => el.classList.contains("feedback-ui-ignore"),
|
||||
});
|
||||
return canvas.toDataURL("image/png");
|
||||
} catch (e) {
|
||||
console.error("Screenshot failed", e);
|
||||
return null;
|
||||
} finally {
|
||||
setIsCapturing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveFeedback = async () => {
|
||||
if (!selectedElement || !currentComment) return;
|
||||
|
||||
const rect = selectedElement.getBoundingClientRect();
|
||||
const screenshot = await captureScreenshot();
|
||||
|
||||
const feedbackData = {
|
||||
url: window.location.href,
|
||||
x: rect.left + rect.width / 2 + window.scrollX,
|
||||
y: rect.top + rect.height / 2 + window.scrollY,
|
||||
selector: getSelector(selectedElement),
|
||||
text: currentComment,
|
||||
type: currentType,
|
||||
userName: currentUser?.identity || "Unknown",
|
||||
userIdentity: currentUser?.identity === "Admin" ? "admin" : "user",
|
||||
screenshot_base64: screenshot,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(feedbackData),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const savedFb = await res.json();
|
||||
const newFeedback: Feedback = {
|
||||
id: savedFb.id,
|
||||
x: savedFb.x,
|
||||
y: savedFb.y,
|
||||
selector: savedFb.selector,
|
||||
text: savedFb.text,
|
||||
type: savedFb.type,
|
||||
elementRect: rect,
|
||||
userName: savedFb.user_name,
|
||||
comments: [],
|
||||
};
|
||||
setFeedbacks([...feedbacks, newFeedback]);
|
||||
setSelectedElement(null);
|
||||
setCurrentComment("");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to save feedback", e);
|
||||
}
|
||||
};
|
||||
|
||||
const addReply = async (feedbackId: string) => {
|
||||
const text = newCommentTexts[feedbackId];
|
||||
if (!text) return;
|
||||
|
||||
if (!currentUser?.identity || currentUser.identity === "Guest") {
|
||||
alert("Nur angemeldete Benutzer können antworten.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "reply",
|
||||
feedbackId,
|
||||
userName: currentUser?.identity || "Unknown",
|
||||
text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const savedReply = await res.json();
|
||||
setFeedbacks(
|
||||
feedbacks.map((f) => {
|
||||
if (f.id === feedbackId) {
|
||||
return {
|
||||
...f,
|
||||
comments: [
|
||||
...f.comments,
|
||||
{
|
||||
id: savedReply.id,
|
||||
userName: savedReply.user_name,
|
||||
text: savedReply.text,
|
||||
createdAt: savedReply.date_created,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return f;
|
||||
}),
|
||||
);
|
||||
setNewCommentTexts({ ...newCommentTexts, [feedbackId]: "" });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to save reply", e);
|
||||
}
|
||||
};
|
||||
|
||||
const hoveredRect = useMemo(
|
||||
() => hoveredElement?.getBoundingClientRect(),
|
||||
[hoveredElement],
|
||||
);
|
||||
const selectedRect = useMemo(
|
||||
() => selectedElement?.getBoundingClientRect(),
|
||||
[selectedElement],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="feedback-ui-ignore">
|
||||
{/* 1. Global Toolbar */}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999]">
|
||||
<div className="bg-black/80 backdrop-blur-xl border border-white/10 p-2 rounded-2xl shadow-2xl flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-xl transition-all",
|
||||
currentUser?.isDevFallback
|
||||
? "bg-orange-500/20 text-orange-400"
|
||||
: "bg-white/5 text-white/40",
|
||||
)}
|
||||
>
|
||||
<User size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">
|
||||
{currentUser?.identity || "Loading..."}
|
||||
{currentUser?.isDevFallback && " (Local Dev Bypass)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!currentUser?.identity || currentUser.identity === "Guest") {
|
||||
alert("Bitte logge dich ein, um Feedback zu geben.");
|
||||
return;
|
||||
}
|
||||
setIsActive(!isActive);
|
||||
}}
|
||||
disabled={
|
||||
!currentUser?.identity || currentUser.identity === "Guest"
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-xl transition-all font-medium disabled:opacity-30 disabled:cursor-not-allowed",
|
||||
isActive
|
||||
? "bg-blue-500 text-white shadow-lg shadow-blue-500/20"
|
||||
: "text-white/70 hover:text-white hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
{isActive ? <X size={18} /> : <MessageSquare size={18} />}
|
||||
{isActive ? "Modus beenden" : "Feedback geben"}
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={() => setShowList(!showList)}
|
||||
className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-xl relative"
|
||||
>
|
||||
<List size={20} />
|
||||
{feedbacks.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-blue-500 text-[10px] flex items-center justify-center rounded-full text-white font-bold border-2 border-[#1a1a1a]">
|
||||
{feedbacks.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Feedback Markers & Highlights */}
|
||||
<AnimatePresence>
|
||||
{isActive && (
|
||||
<>
|
||||
{/* Fixed Overlay for real-time highlights */}
|
||||
<div className="fixed inset-0 pointer-events-none z-[9998]">
|
||||
{hoveredRect && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute border-2 border-blue-400 bg-blue-400/10 rounded-sm transition-all duration-200"
|
||||
style={{
|
||||
top: hoveredRect.top,
|
||||
left: hoveredRect.left,
|
||||
width: hoveredRect.width,
|
||||
height: hoveredRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedRect && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="absolute border-2 border-yellow-400 bg-yellow-400/20 rounded-sm"
|
||||
style={{
|
||||
top: selectedRect.top,
|
||||
left: selectedRect.left,
|
||||
width: selectedRect.width,
|
||||
height: selectedRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Absolute Overlay for persistent pins */}
|
||||
<div className="absolute inset-0 pointer-events-none z-[9997]">
|
||||
{feedbacks.map((fb) => (
|
||||
<div
|
||||
key={fb.id}
|
||||
className="absolute"
|
||||
style={{ top: fb.y, left: fb.x }}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowList(true);
|
||||
}}
|
||||
className={cn(
|
||||
"w-6 h-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white cursor-pointer pointer-events-auto transition-transform hover:scale-110",
|
||||
fb.type === "design" ? "bg-purple-500" : "bg-orange-500",
|
||||
)}
|
||||
>
|
||||
<Plus size={14} className="rotate-45" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 3. Feedback Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedElement && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-black/40 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
className="bg-[#1c1c1e] border border-white/10 rounded-3xl p-6 w-[400px] shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-white font-bold text-lg">Feedback geben</h3>
|
||||
<button
|
||||
onClick={() => setSelectedElement(null)}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6">
|
||||
{(["design", "content"] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setCurrentType(type)}
|
||||
className={cn(
|
||||
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all capitalize",
|
||||
currentType === type
|
||||
? "bg-white text-black shadow-lg"
|
||||
: "bg-white/5 text-white/40 hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
{type === "design" ? "🎨 Design" : "✍️ Content"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
autoFocus
|
||||
value={currentComment}
|
||||
onChange={(e) => setCurrentComment(e.target.value)}
|
||||
placeholder="Was möchtest du anmerken?"
|
||||
className="w-full h-32 bg-white/5 border border-white/5 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none mb-6"
|
||||
/>
|
||||
|
||||
<button
|
||||
disabled={!currentComment || isCapturing}
|
||||
onClick={saveFeedback}
|
||||
className="w-full bg-blue-500 hover:bg-blue-400 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
{isCapturing ? (
|
||||
"Erfasse Screenshot..."
|
||||
) : (
|
||||
<>
|
||||
<Check size={20} />
|
||||
Feedback speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 4. Feedback List Sidebar */}
|
||||
<AnimatePresence>
|
||||
{showList && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setShowList(false)}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[10001]"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
className="fixed top-0 right-0 h-full w-[400px] bg-[#1c1c1e] border-l border-white/10 z-[10002] shadow-2xl flex flex-col"
|
||||
>
|
||||
<div className="p-8 border-b border-white/10 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">
|
||||
Feedback
|
||||
</h2>
|
||||
<p className="text-white/40 text-sm">
|
||||
{feedbacks.length} Anmerkungen live
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowList(false)}
|
||||
className="p-2 text-white/40 hover:text-white bg-white/5 rounded-xl transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{feedbacks.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-8 opacity-40">
|
||||
<MessageSquare size={48} className="mb-4" />
|
||||
<p>
|
||||
Noch kein Feedback vorhanden. Aktiviere den Modus um
|
||||
Stellen auf der Seite zu markieren.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
feedbacks.map((fb) => (
|
||||
<div
|
||||
key={fb.id}
|
||||
className="bg-white/5 border border-white/5 rounded-3xl overflow-hidden hover:border-white/20 transition-all flex flex-col"
|
||||
>
|
||||
<div className="p-5 border-b border-white/5 bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center text-blue-400">
|
||||
<User size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white text-[11px] font-bold uppercase tracking-wider">
|
||||
{fb.userName}
|
||||
</p>
|
||||
<p className="text-white/20 text-[9px] uppercase tracking-widest">
|
||||
Original Poster
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-full text-[9px] font-bold uppercase tracking-wider",
|
||||
fb.type === "design"
|
||||
? "bg-purple-500/20 text-purple-400"
|
||||
: "bg-orange-500/20 text-orange-400",
|
||||
)}
|
||||
>
|
||||
{fb.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-white/80 whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{fb.text}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="w-1 h-1 bg-white/10 rounded-full" />
|
||||
<span className="text-white/20 text-[9px] truncate tracking-wider italic">
|
||||
{fb.selector}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fb.comments.length > 0 && (
|
||||
<div className="bg-black/20 p-5 space-y-4">
|
||||
{fb.comments.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center text-white/40 shrink-0">
|
||||
<User size={10} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[10px] font-bold text-white/60 uppercase">
|
||||
{comment.userName}
|
||||
</p>
|
||||
<p className="text-[10px] text-white/20">
|
||||
{new Date(
|
||||
comment.createdAt,
|
||||
).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-white/80 text-xs leading-snug">
|
||||
{comment.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-white/[0.01] mt-auto border-t border-white/5">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={newCommentTexts[fb.id] || ""}
|
||||
onChange={(e) =>
|
||||
setNewCommentTexts({
|
||||
...newCommentTexts,
|
||||
[fb.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Antworten..."
|
||||
className="w-full bg-black/40 border border-white/5 rounded-2xl py-3 pl-4 pr-12 text-xs text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") addReply(fb.id);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => addReply(fb.id)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-blue-500 hover:text-blue-400 transition-colors disabled:opacity-30"
|
||||
disabled={!newCommentTexts[fb.id]}
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
packages/next-feedback/src/handlers/index.ts
Normal file
131
packages/next-feedback/src/handlers/index.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
createDirectus,
|
||||
rest,
|
||||
staticToken,
|
||||
createItem,
|
||||
readItems,
|
||||
} from "@directus/sdk";
|
||||
|
||||
export interface CMSConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function createCMSClient(config: CMSConfig) {
|
||||
return createDirectus(config.url)
|
||||
.with(staticToken(config.token))
|
||||
.with(rest());
|
||||
}
|
||||
|
||||
export async function handleFeedbackRequest(
|
||||
req: NextRequest,
|
||||
config: CMSConfig,
|
||||
) {
|
||||
const client = createCMSClient(config);
|
||||
|
||||
if (req.method === "GET") {
|
||||
try {
|
||||
const items = await client.request(
|
||||
readItems("visual_feedback", {
|
||||
fields: ["*", { comments: ["*"] }],
|
||||
sort: ["-date_created"],
|
||||
}),
|
||||
);
|
||||
return NextResponse.json(items);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { action, screenshot_base64, ...data } = body;
|
||||
|
||||
if (action === "reply") {
|
||||
const reply = await client.request(
|
||||
createItem("visual_feedback_comments", {
|
||||
feedback_id: data.feedbackId,
|
||||
user_name: data.userName,
|
||||
text: data.text,
|
||||
}),
|
||||
);
|
||||
return NextResponse.json(reply);
|
||||
}
|
||||
|
||||
let screenshotId = null;
|
||||
|
||||
if (screenshot_base64) {
|
||||
try {
|
||||
const base64Data = screenshot_base64.split(";base64,").pop();
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([buffer], { type: "image/png" });
|
||||
formData.append("file", blob, `feedback-${Date.now()}.png`);
|
||||
|
||||
const fileRes = await fetch(`${config.url}/files`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${config.token}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (fileRes.ok) {
|
||||
const fileData = await fileRes.json();
|
||||
screenshotId = fileData.data.id;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to upload screenshot:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const feedback = await client.request(
|
||||
createItem("visual_feedback", {
|
||||
project: data.project || req.headers.get("host") || "unknown",
|
||||
url: data.url,
|
||||
selector: data.selector,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
type: data.type,
|
||||
text: data.text,
|
||||
user_name: data.userName,
|
||||
user_identity: data.userIdentity,
|
||||
status: "open",
|
||||
screenshot: screenshotId,
|
||||
company: data.companyId,
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json(feedback);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
export async function handleWhoAmIRequest(
|
||||
req: NextRequest,
|
||||
gatekeeperUrl: string,
|
||||
) {
|
||||
try {
|
||||
const bypass = req.nextUrl.searchParams.get("gatekeeper_bypass");
|
||||
const targetUrl = bypass
|
||||
? `${gatekeeperUrl}/api/whoami?gatekeeper_bypass=${bypass}`
|
||||
: `${gatekeeperUrl}/api/whoami`;
|
||||
|
||||
// Forward cookies
|
||||
const cookieHeader = req.headers.get("cookie") || "";
|
||||
const res = await fetch(targetUrl, {
|
||||
headers: { Cookie: cookieHeader },
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
return NextResponse.json({ identity: "Guest" });
|
||||
} catch (_e) {
|
||||
return NextResponse.json({ identity: "Guest" });
|
||||
}
|
||||
}
|
||||
2
packages/next-feedback/src/index.ts
Normal file
2
packages/next-feedback/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./handlers";
|
||||
export * from "./components/FeedbackOverlay";
|
||||
10
packages/next-feedback/tsconfig.json
Normal file
10
packages/next-feedback/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
12
packages/next-feedback/tsup.config.ts
Normal file
12
packages/next-feedback/tsup.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts", "src/components/FeedbackOverlay.tsx"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
banner: {
|
||||
js: "'use client';",
|
||||
},
|
||||
});
|
||||
@@ -29,7 +29,7 @@
|
||||
"dependencies": {
|
||||
"@mintel/observability": "workspace:*",
|
||||
"@sentry/nextjs": "^8.55.0",
|
||||
"next": "15.1.6"
|
||||
"next": "15.1.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
|
||||
@@ -37,7 +37,7 @@ export function createUmamiProxyHandler(config: {
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: "ok" });
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 },
|
||||
@@ -80,7 +80,7 @@ export function createSentryRelayHandler(config: { dsn?: string }) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: "ok" });
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 },
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^21.0.0",
|
||||
"next": "15.1.6",
|
||||
"next": "15.1.7",
|
||||
"next-intl": "^4.8.2",
|
||||
"zod": "^3.0.0"
|
||||
},
|
||||
|
||||
@@ -5,11 +5,11 @@ import type { AnalyticsService, AnalyticsEventProperties } from "./service";
|
||||
* Used when analytics are disabled or for local development.
|
||||
*/
|
||||
export class NoopAnalyticsService implements AnalyticsService {
|
||||
track(eventName: string, props?: AnalyticsEventProperties): void {
|
||||
track(_eventName: string, _props?: AnalyticsEventProperties): void {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
trackPageview(url?: string): void {
|
||||
trackPageview(_url?: string): void {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
||||
7738
pnpm-lock.yaml
generated
7738
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
packages:
|
||||
- 'packages/*'
|
||||
- 'apps/*'
|
||||
- '../klz-2026'
|
||||
|
||||
Reference in New Issue
Block a user