feat: cms feedback and customer management

This commit is contained in:
2026-02-09 20:02:52 +01:00
parent a306d24f51
commit 625c58398c
31 changed files with 18998 additions and 385 deletions

3
.npmrc
View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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)

View File

@@ -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"
}
}

View File

@@ -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

View File

@@ -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>
<!-- 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>
<v-button primary block :loading="saving" @click="saveEmployee">Daten speichern</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>

View File

@@ -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",
},

View File

@@ -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",

View File

@@ -1,3 +1,4 @@
/* global module */
module.exports = {
plugins: {
tailwindcss: {},

View File

@@ -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) {

View File

@@ -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")],
};

View File

@@ -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 = {

View File

@@ -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 />

View File

@@ -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>

View File

@@ -1,3 +1,4 @@
/* global process, URL */
import createNextIntlPlugin from "next-intl/plugin";
import { withSentryConfig } from "@sentry/nextjs";
import fs from "node:fs";

View 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"
}
}

View 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>
);
}

View 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" });
}
}

View File

@@ -0,0 +1,2 @@
export * from "./handlers";
export * from "./components/FeedbackOverlay";

View File

@@ -0,0 +1,10 @@
{
"extends": "@mintel/tsconfig/nextjs.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"baseUrl": "."
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View 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';",
},
});

View File

@@ -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",

View File

@@ -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 },

View File

@@ -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"
},

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
packages:
- 'packages/*'
- 'apps/*'
- '../klz-2026'