feat: added speech integration prototype

This commit is contained in:
Ida Dittrich 2025-09-15 11:50:40 +02:00
parent 0bd60916c9
commit 9fc33c7c73
42 changed files with 4971 additions and 124 deletions

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="60" fill="none" xmlns:v="https://vecta.io/nano"><g clip-path="url(#A)" fill="#fffefd"><path d="M9.001 28.103c-3.277-.84-4.094-1.246-4.094-2.492v-.055c0-.923.84-1.657 2.442-1.657s3.249.706 4.929 1.869l2.169-3.143c-1.92-1.546-4.278-2.414-7.043-2.414-3.877 0-6.637 2.275-6.637 5.718v.055c0 3.766 2.465 4.823 6.286 5.802 3.171.812 3.822 1.357 3.822 2.414v.055c0 1.112-1.029 1.791-2.737 1.791-2.169 0-3.955-.895-5.663-2.303L.01 36.697c2.275 2.031 5.174 3.032 8.049 3.032 4.094 0 6.965-2.114 6.965-5.88v-.055c0-3.305-2.169-4.689-6.018-5.691h-.005zm22.582-.923c0 1.625-1.218 2.871-3.305 2.871h-3.309v-5.797h3.226c2.086 0 3.388 1.002 3.388 2.871v.055zm-3.037-6.692h-7.749v18.969h4.172v-5.691h3.171c4.251 0 7.671-2.275 7.671-6.665v-.055c0-3.877-2.737-6.558-7.265-6.558zm16.939 0h-4.172v18.969h4.172V20.488zm4.384 3.664v.18h5.769v15.125h4.172V24.332h5.774v-3.845H49.869v3.665zm27.794 11.783c-3.254 0-5.501-2.709-5.501-5.963v-.055c0-3.249 2.303-5.908 5.501-5.908 1.897 0 3.388.812 4.846 2.142l2.654-3.06c-1.758-1.735-3.905-2.926-7.477-2.926-5.829 0-9.891 4.417-9.891 9.808v.055c0 5.446 4.145 9.757 9.729 9.757 3.66 0 5.825-1.301 7.777-3.388l-2.654-2.681c-1.491 1.352-2.82 2.22-4.985 2.22zm23.746-15.447v7.509h-7.694v-7.509h-4.172v18.969h4.172v-7.615h7.694v7.615h4.172V20.488h-4.172zM56.995 0h-8.571l4.288 7.615L56.995 0zm-8.571 60h8.571l-4.283-7.615L48.424 60z"/></g><defs><clipPath id="A"><path fill="#fff" d="M0 0h105.582v60H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -74,10 +74,17 @@ api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Clear invalid token
localStorage.removeItem('auth_data');
// Redirect to login
window.location.href = '/login';
// Don't redirect to login if the request was to a login endpoint
const isLoginEndpoint = error.config?.url?.includes('/login') ||
error.config?.url?.includes('/api/local/login') ||
error.config?.url?.includes('/api/msft/login');
if (!isLoginEndpoint) {
// Clear invalid token
localStorage.removeItem('auth_data');
// Redirect to login
window.location.href = '/login';
}
}
return Promise.reject(error);
}

View file

@ -94,7 +94,19 @@ export function useConnectionsLogic(): ConnectionsLogicReturn {
if (!value) return t('connections.not_available', 'N/A');
try {
// Convert from seconds to milliseconds for Date constructor
return new Date(value * 1000).toLocaleString();
const date = new Date(value * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
} catch {
return t('connections.invalid_date', 'Invalid Date');
}
@ -109,7 +121,19 @@ export function useConnectionsLogic(): ConnectionsLogicReturn {
if (!value) return t('connections.not_available', 'N/A');
try {
// Convert from seconds to milliseconds for Date constructor
return new Date(value * 1000).toLocaleString();
const date = new Date(value * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
} catch {
return t('connections.invalid_date', 'Invalid Date');
}
@ -174,7 +198,28 @@ export function useConnectionsLogic(): ConnectionsLogicReturn {
width: 150,
sortable: true,
filterable: false,
searchable: true
searchable: true,
formatter: (value: number) => {
if (!value) return t('connections.not_available', 'N/A');
try {
// Convert from seconds to milliseconds for Date constructor
const date = new Date(value * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
} catch {
return t('connections.invalid_date', 'Invalid Date');
}
}
},
{
key: 'lastChecked',
@ -183,7 +228,28 @@ export function useConnectionsLogic(): ConnectionsLogicReturn {
width: 150,
sortable: true,
filterable: true,
searchable: true
searchable: true,
formatter: (value: number) => {
if (!value) return t('connections.not_available', 'N/A');
try {
// Convert from seconds to milliseconds for Date constructor
const date = new Date(value * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
} catch {
return t('connections.invalid_date', 'Invalid Date');
}
}
},
{
key: 'expiresAt',
@ -191,7 +257,28 @@ export function useConnectionsLogic(): ConnectionsLogicReturn {
type: 'date',
width: 150,
sortable: true,
filterable: true
filterable: true,
formatter: (value: number) => {
if (!value) return t('connections.not_available', 'N/A');
try {
// Convert from seconds to milliseconds for Date constructor
const date = new Date(value * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
} catch {
return t('connections.invalid_date', 'Invalid Date');
}
}
}
];

View file

@ -14,6 +14,7 @@ export function DateienTable({ className = '' }: DateienTableProps) {
files,
loading,
error,
refetch,
columns,
actions,
editModalOpen,
@ -57,6 +58,7 @@ export function DateienTable({ className = '' }: DateienTableProps) {
onRowClick={undefined}
onDelete={handleDelete}
onDeleteMultiple={handleDeleteMultiple}
onRefresh={refetch}
actions={actions}
className={styles.dateienFormGenerator}
/>

View file

@ -139,7 +139,12 @@ export function useDateienLogic(): DateienLogicReturn {
const hh = pad(date.getHours());
const mi = pad(date.getMinutes());
const ss = pad(date.getSeconds());
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`;
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss} ${timezone}`;
} catch {
return '-';
}

View file

@ -92,6 +92,41 @@
flex-shrink: 0;
}
.refreshButton {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: 1px solid var(--color-primary);
border-radius: 50%;
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
transition: all 0.2s ease;
font-size: 16px;
font-family: var(--font-family);
flex-shrink: 0;
}
.refreshButton:hover:not(:disabled) {
background: var(--color-secondary);
color: white;
border-color: var(--color-secondary);
}
.refreshButton:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.refreshIcon {
font-size: 18px;
font-weight: bold;
transition: transform 0.2s ease;
}
.floatingLabelInput {
position: relative;
width: 250px;

View file

@ -2,6 +2,8 @@ import React, { useState, useMemo, useRef, useEffect } from 'react';
import { useLanguage } from '../../contexts/LanguageContext';
import styles from './FormGenerator.module.css';
import { IoIosRefresh } from "react-icons/io";
// Types for the FormGenerator
export interface ColumnConfig {
key: string;
@ -41,6 +43,7 @@ export interface FormGeneratorProps<T = any> {
}[];
onDelete?: (row: T) => void;
onDeleteMultiple?: (rows: T[]) => void;
onRefresh?: () => void;
className?: string;
}
@ -63,6 +66,7 @@ export function FormGenerator<T extends Record<string, any>>({
actions = [],
onDelete,
onDeleteMultiple,
onRefresh,
className = ''
}: FormGeneratorProps<T>) {
const { t } = useLanguage();
@ -370,7 +374,24 @@ export function FormGenerator<T extends Record<string, any>>({
switch (column.type) {
case 'date':
return new Date(value).toLocaleDateString();
try {
const date = new Date(value);
if (isNaN(date.getTime())) return '-';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
} catch {
return '-';
}
case 'boolean':
return value ? '✓' : '✗';
case 'number':
@ -431,6 +452,16 @@ export function FormGenerator<T extends Record<string, any>>({
/>
<label className={searchFocused || searchTerm ? styles.focusedLabel : styles.label}>{t('formgen.search.placeholder')}</label>
</div>
{onRefresh && (
<button
onClick={onRefresh}
className={styles.refreshButton}
title={t('formgen.refresh.tooltip', 'Refresh data')}
disabled={loading}
>
<span className={styles.refreshIcon}><IoIosRefresh /></span>
</button>
)}
</div>
)}

View file

@ -41,6 +41,7 @@ function MitgliederTable({ className = '' }: MitgliederTableProps) {
selectable={false}
loading={loading}
actions={actions}
onRefresh={refetch}
className={styles.mitgliederFormGenerator}
/>
</div>

View file

@ -28,10 +28,34 @@ const PageManager: React.FC<PageManagerProps> = ({ loadingComponent: LoadingComp
const currentPath = getCurrentPath();
// Check if user has access to speech-related pages
const checkSpeechAccess = (path: string) => {
if (path.startsWith('speech/transcripts')) {
try {
const savedData = localStorage.getItem('speechSignUpData');
const timestamp = localStorage.getItem('speechSignUpTimestamp');
if (savedData && timestamp) {
const savedTime = parseInt(timestamp);
const now = Date.now();
const twentyFourHours = 24 * 60 * 60 * 1000;
// Check if data is still valid (within 24 hours)
return (now - savedTime) < twentyFourHours;
}
return false;
} catch (error) {
console.error('Error checking speech access:', error);
return false;
}
}
return true; // Allow access to non-speech pages
};
useEffect(() => {
const config = pageConfigs.find(c => c.path === currentPath);
if (!config || !config.moduleEnabled) {
if (!config || !config.moduleEnabled || !checkSpeechAccess(currentPath)) {
return;
}
@ -110,7 +134,7 @@ const PageManager: React.FC<PageManagerProps> = ({ loadingComponent: LoadingComp
const config = pageConfigs.find(c => c.path === currentPath);
if (!config || !config.moduleEnabled) {
if (!config || !config.moduleEnabled || !checkSpeechAccess(currentPath)) {
return <ErrorComponent />;
}

View file

@ -22,6 +22,9 @@ export interface PageConfig {
order?: number; // For sidebar ordering
showInSidebar?: boolean; // Whether to show in sidebar (default: true)
// Subpages support
subpages?: PageConfig[];
// Lifecycle hooks
onActivate?: () => void | Promise<void>;
onDeactivate?: () => void | Promise<void>;
@ -37,4 +40,5 @@ export interface SidebarItem {
icon: IconType;
moduleEnabled: boolean;
order: number;
submenu?: SidebarSubmenuItemData[];
}

View file

@ -1,4 +1,4 @@
import { PageConfig } from './pageConfigInterface';
import { PageConfig, SidebarItem } from './pageConfigInterface';
import { lazy } from 'react';
// Import icons for sidebar
@ -7,6 +7,8 @@ import { LuWorkflow, LuTicket } from "react-icons/lu";
import { GoGear } from "react-icons/go";
import { FaPlug, FaRegFileAlt } from "react-icons/fa";
import { LuMessageSquareText } from "react-icons/lu";
import { IoIosDocument } from "react-icons/io";
import Speech from '../../pages/Home/Speech';
// Lazy load components for better performance
const Dashboard = lazy(() => import('../../pages/Home/Dashboard'));
@ -15,8 +17,8 @@ const TeamBereich = lazy(() => import('../../pages/Home/TeamBereich'));
const Connections = lazy(() => import('../../pages/Home/Connections'));
const Workflows = lazy(() => import('../../pages/Home/Workflows'));
const Einstellungen = lazy(() => import('../../pages/Home/Einstellungen'));
const Prompts = lazy(() => import('../../pages/Home/Prompts'));
const SpeechTranscripts = lazy(() => import('../../pages/Home/SpeechTranscripts'));
// Page configuration with caching and lifecycle settings
export const pageConfigs: PageConfig[] = [
@ -160,6 +162,38 @@ export const pageConfigs: PageConfig[] = [
onActivate: async () => {
if (import.meta.env.DEV) console.log('Einstellungen activated');
}
},
{
path: 'speech',
component: Speech,
persistent: false,
preload: true,
moduleEnabled: true,
// Sidebar properties
id: '8',
name: 'Speech',
icon: FaRegFileAlt,
order: 7,
showInSidebar: true,
onActivate: async () => {
if (import.meta.env.DEV) console.log('Speech activated');
}
},
{
path: 'speech/transcripts',
component: SpeechTranscripts,
persistent: false,
preload: false,
moduleEnabled: true,
// Sidebar properties
id: '8-1',
name: 'Transkriptverwaltung',
icon: IoIosDocument,
order: 8,
showInSidebar: false, // Will be shown as subpage under Speech
onActivate: async () => {
if (import.meta.env.DEV) console.log('Speech Transcripts activated');
}
}
];
@ -197,17 +231,104 @@ export const getPageConfig = (path: string): PageConfig | undefined => {
// Get sidebar items from page configs
export const getSidebarItems = () => {
return pageConfigs
const items: SidebarItem[] = [];
pageConfigs
.filter(config => config.showInSidebar !== false)
.sort((a, b) => (a.order || 0) - (b.order || 0))
.map(config => ({
id: config.id,
name: config.name,
link: `/${config.path}`,
icon: config.icon,
moduleEnabled: config.moduleEnabled ?? true,
order: config.order || 0
}));
.forEach(config => {
// Check if this is the Speech item and if user has signed up
if (config.path === 'speech') {
const hasSignedUp = checkSpeechSignUpStatus();
if (hasSignedUp) {
// Find the transcript subpage
const transcriptConfig = pageConfigs.find(c => c.path === 'speech/transcripts');
if (transcriptConfig) {
// Create expandable Speech item with submenu
items.push({
id: config.id,
name: config.name,
link: `/${config.path}`,
icon: config.icon,
moduleEnabled: config.moduleEnabled ?? true,
order: config.order || 0,
submenu: [
{
id: transcriptConfig.id,
name: transcriptConfig.name,
link: `/${transcriptConfig.path}`
}
]
});
} else {
// Fallback to regular item if transcript config not found
items.push({
id: config.id,
name: config.name,
link: `/${config.path}`,
icon: config.icon,
moduleEnabled: config.moduleEnabled ?? true,
order: config.order || 0
});
}
} else {
// User hasn't signed up, show regular non-expandable item
items.push({
id: config.id,
name: config.name,
link: `/${config.path}`,
icon: config.icon,
moduleEnabled: config.moduleEnabled ?? true,
order: config.order || 0
});
}
} else {
// Regular non-Speech items
items.push({
id: config.id,
name: config.name,
link: `/${config.path}`,
icon: config.icon,
moduleEnabled: config.moduleEnabled ?? true,
order: config.order || 0
});
}
});
return items;
};
// Helper function to check speech sign-up status
const checkSpeechSignUpStatus = (): boolean => {
try {
const savedData = localStorage.getItem('speechSignUpData');
const timestamp = localStorage.getItem('speechSignUpTimestamp');
console.log('🔍 Checking speech sign-up status:', { savedData: !!savedData, timestamp });
if (savedData && timestamp) {
const signUpTime = parseInt(timestamp);
const now = Date.now();
const hoursDiff = (now - signUpTime) / (1000 * 60 * 60);
console.log('📊 Speech sign-up validation:', {
signUpTime,
now,
hoursDiff,
isValid: hoursDiff < 24
});
// Data is valid for 24 hours
return hoursDiff < 24;
}
console.log('❌ No speech sign-up data found');
return false;
} catch (error) {
console.error('Error checking speech sign-up status:', error);
return false;
}
};
export default pageConfigs;

View file

@ -23,6 +23,7 @@
background: var(--color-bg);
gap: 20px;
min-height: 100%;
overflow: hidden; /* Prevent card from expanding beyond viewport */
}
/* Page headers with consistent spacing */
@ -45,11 +46,24 @@
}
.pageSubtitle {
font-size: 1.3rem;
font-weight: 600;
color: var(--color-secondary);
margin: 0;
font-family: var(--font-family);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Common button styles */
.primaryButton {
border-radius: 30px;
background: var(--color-secondary);
color: var(--color-bg);
color: white;
border: none;
outline: none;
padding: 10px 20px;
@ -109,7 +123,8 @@
flex-direction: column;
gap: 10px;
flex: 1;
overflow-y: auto;
max-height: calc(100vh - 200px); /* Account for header and padding */
}
.scrollableContent {

View file

@ -76,6 +76,7 @@ function PromptsTable({ className = '' }: PromptsTableProps) {
actions={actions}
onDelete={handleDeleteSingle}
onDeleteMultiple={handleDeleteMultiple}
onRefresh={refetch}
className={styles.promptsFormGenerator}
/>

View file

@ -23,10 +23,9 @@ const SidebarItem: React.FC<SidebarItemProps> = ({
e.preventDefault();
return;
}
if (hasSubItems) {
e.preventDefault();
onToggle();
}
e.preventDefault();
e.stopPropagation();
onToggle();
};
const handleLinkClick = (e: React.MouseEvent) => {
@ -34,40 +33,42 @@ const SidebarItem: React.FC<SidebarItemProps> = ({
e.preventDefault();
return;
}
// Allow normal navigation for the main link
};
return (
<div className={`${styles.menu} ${isMinimized ? styles.minimized : ''} ${isDisabled ? styles.disabled : ''}`}>
<li className={`${isActive ? styles.active : ""} ${isDisabled ? styles.disabledItem : ""}`}>
{/* Icon is always present */}
{/* Icon - always visible */}
{Icon && <Icon className={`${styles.icon} ${isDisabled ? styles.disabledIcon : ''}`} />}
{/* Text content - always present but hidden when minimized */}
{hasSubItems ? (
<a
href="#"
onClick={toggleSubmenu}
className={`${styles.menuLink} ${isDisabled ? styles.disabledLink : ''}`}
aria-disabled={isDisabled}
title={isDisabled ? `${item.name} (Module disabled)` : item.name}
>
<span className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}>
{item.name}
</span>
<IoIosArrowDown className={`${styles.hassubmenu} ${isOpen ? styles.rotated : ''} ${isDisabled ? styles.disabledArrow : ''}`} />
</a>
) : (
<Link
to={isDisabled ? "#" : (item.link || "#")}
className={`${styles.menuLink} ${isDisabled ? styles.disabledLink : ''}`}
onClick={handleLinkClick}
aria-disabled={isDisabled}
title={isDisabled ? `${item.name} (Module disabled)` : item.name}
>
<span className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}>
{item.name}
</span>
</Link>
{/* Text and arrow - hidden when minimized */}
{!isMinimized && (
<>
<Link
to={isDisabled ? "#" : (item.link || "#")}
className={`${styles.menuTextLink} ${isDisabled ? styles.disabledLink : ''}`}
onClick={handleLinkClick}
aria-disabled={isDisabled}
title={isDisabled ? `${item.name} (Module disabled)` : item.name}
>
<span className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}>
{item.name}
</span>
</Link>
{/* Arrow button separate from link */}
{hasSubItems && (
<button
onClick={toggleSubmenu}
className={`${styles.arrowButton} ${isDisabled ? styles.disabledArrow : ''}`}
aria-disabled={isDisabled}
title={isDisabled ? `${item.name} (Module disabled)` : `Toggle ${item.name} submenu`}
>
<IoIosArrowDown className={`${styles.hassubmenu} ${isOpen ? styles.rotated : ''} ${isDisabled ? styles.disabledArrow : ''}`} />
</button>
)}
</>
)}

View file

@ -17,50 +17,72 @@
color: var(--color-text);
list-style: none;
position: relative;
gap: 8px;
}
.menu li:hover, .menu li.active {
background: var(--color-secondary);
color: var(--color-bg);
color: white;
border-top-right-radius: 25px;
border-bottom-right-radius: 25px;
}
.menu li:hover a, .menu li.active a {
color: var(--color-bg);
.menu li:hover a , .menu li.active a {
color: white;
text-decoration: none;
}
.menu li a {
.menuTextLink {
flex: 1;
text-decoration: none;
color: inherit;
display: flex;
align-items: center;
padding: 0;
margin: 0;
background: none;
border: none;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
gap: 5px;
font-family: var(--font-family);
font-size: 0.9rem;
font-style: normal;
font-weight: 500;
line-height: normal;
color: inherit;
display: flex;
align-items: center;
justify-content: left;
position: absolute;
height: 100%;
opacity: 1;
white-space: nowrap;
overflow: hidden;
transition: opacity 0.3s ease-in-out;
}
.menuLink {
.arrowButton {
background: none;
border: none;
padding: 4px;
margin-right: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 15px;
padding-left: 35px;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s ease;
flex-shrink: 0;
width: 24px;
height: 24px;
color:var(--color-text);
}
.menu li:hover .arrowButton {
color: white;
}
.arrowButton:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.icon {
@ -71,17 +93,13 @@
justify-content: center;
align-items: center;
flex-shrink: 0;
flex-grow: 0; /* Prevent growth during transitions */
position: absolute;
top: 50%;
transform: translateY(-50%); /* Center vertically */
flex-grow: 0;
}
.hassubmenu {
width: 20px;
height: 20px;
opacity: 1;
margin-left: auto; /* Push to right side, replacing gap spacing */
transition: opacity 0.3s ease-in-out;
}
@ -90,10 +108,7 @@
}
.menuText {
white-space: nowrap;
overflow: hidden;
opacity: 1;
margin-left: 5px; /* Replace gap with margin to prevent layout shifts */
transition: opacity 0.3s ease-in-out;
}
@ -109,8 +124,8 @@
/* Minimized Menu Styles */
.menu.minimized li {
width: 46px;
padding: 0 3px 0 11px;
justify-content: flex-start;
padding: 0;
justify-content: center;
position: relative;
}
.menu.minimized li a{

View file

@ -1,54 +1,65 @@
.submenu {
position: relative;
background-color: var(--color-bg);
background: var(--color-primary);
border: none;
border-top-right-radius: 25px;
border-bottom-right-radius: 25px;
z-index: 1000;
margin: 0;
overflow: hidden;
width: 220px;
}
.open {
opacity: 1;
visibility: visible;
height: auto;
}
.submenuLineContainer {
display: flex;
align-items: stretch;
gap: 0;
padding: 5px 0;
padding: 0;
margin: 0;
}
.submenuList {
margin: 5px 0;
margin: 0;
flex-grow: 1;
list-style: none;
padding: 0;
}
.submenuList li {
width: 153px;
height: 20px;
color: var(--color-text);
margin: 4px 0;
width: 100%;
height: 44px;
color: #181818;
margin: 0;
position: relative;
overflow: hidden;
}
.submenuList li a {
padding: 4px 0;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: normal;
color: var(--color-text);
text-decoration: none;
display: block;
overflow: hidden;
width: 100%;
height: 100%;
padding: 0 3px 0 27px;
background: none;
border: none;
text-align: left;
cursor: pointer;
font-family: var(--font-family);
font-size: 0.9rem;
color: #181818;
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
transition: background-color 0.2s ease;
}
.submenuList li a:hover {
background-color: var(--color-hover, rgba(0, 0, 0, 0.05));
}
.textContainer {
position: relative;
width: 153px;
width: 100%;
overflow: hidden;
white-space: nowrap;
}

View file

@ -0,0 +1,331 @@
.container {
max-width: 100%;
margin: 0;
padding: 1.5rem;
text-align: center;
height: calc(100vh - 120px);
overflow-y: auto;
}
.content {
background: var(--color-bg);
padding: 2rem;
border-radius: 25px;
border: 1px solid var(--color-secondary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
margin-bottom: 1.5rem;
}
.iconContainer {
margin-bottom: 2rem;
}
.successIcon {
font-size: 4rem;
color: var(--color-secondary);
}
.title {
font-size: 1.8rem;
font-weight: bold;
margin: 0 0 1rem 0;
color: var(--color-text);
}
.message {
font-size: 1rem;
color: var(--color-text);
line-height: 1.6;
margin-bottom: 2rem;
}
.nextSteps {
text-align: left;
margin-bottom: 2rem;
}
.nextSteps h2 {
font-size: 1.2rem;
font-weight: bold;
margin: 0 0 1.5rem 0;
color: var(--color-secondary);
text-align: center;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--color-secondary);
}
.step {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1.25rem;
background: var(--color-bg);
border-radius: 20px;
border: 1px solid var(--color-secondary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.stepIcon {
flex-shrink: 0;
width: 40px;
height: 40px;
background: var(--color-secondary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.stepIconInner {
color: white;
font-size: 1.2rem;
}
.stepContent h3 {
font-size: 1rem;
font-weight: bold;
margin: 0 0 0.5rem 0;
color: var(--color-text);
}
.stepContent p {
color: var(--color-text);
margin: 0;
line-height: 1.5;
}
.contactInfo {
background: var(--color-bg);
padding: 1.5rem;
border-radius: 20px;
border: 1px solid var(--color-secondary);
text-align: left;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.contactInfo h3 {
font-size: 1.1rem;
font-weight: bold;
margin: 0 0 0.5rem 0;
color: var(--color-secondary);
text-align: center;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--color-secondary);
}
.contactInfo p {
color: var(--color-text);
margin: 0 0 1rem 0;
line-height: 1.5;
}
.contactMethods {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.contactMethod {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text);
}
.contactIcon {
color: var(--color-secondary);
font-size: 1rem;
}
.submittedData {
background: var(--color-bg);
padding: 1.5rem;
border-radius: 20px;
border: 1px solid var(--color-secondary);
margin-bottom: 2rem;
text-align: left;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.submittedData h3 {
font-size: 1.1rem;
font-weight: bold;
margin: 0 0 1rem 0;
color: var(--color-secondary);
text-align: center;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--color-secondary);
}
.dataGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.dataItem {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.dataItem strong {
color: var(--color-text);
font-size: 0.9rem;
font-weight: 600;
}
.dataItem span {
color: var(--color-text);
font-size: 0.9rem;
word-break: break-word;
}
.actions {
display: flex;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
}
.backButton {
padding: 8px 16px;
border: 1px solid var(--color-primary);
background-color: var(--color-bg);
color: var(--color-text);
border-radius: 25px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.backButton:hover {
background-color: var(--color-primary-hover);
border-color: var(--color-primary);
color: #181818;
}
.resetButton {
padding: 8px 16px;
border: none;
background-color: var(--color-secondary);
color: var(--color-bg);
border-radius: 25px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.resetButton:hover {
background-color: var(--color-secondary-hover);
}
.resetIcon {
font-size: 1rem;
}
.additionalActions {
display: flex;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-secondary);
}
.transcriptButton {
padding: 8px 16px;
border: none;
background-color: var(--color-secondary);
color: var(--color-bg);
border-radius: 25px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.transcriptButton:hover {
background-color: var(--color-secondary-hover);
}
.settingsButton {
padding: 8px 16px;
border: 1px solid var(--color-primary);
background-color: var(--color-bg);
color: var(--color-text);
border-radius: 25px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.settingsButton:hover {
background-color: var(--color-primary-hover);
border-color: var(--color-primary);
color: #181818;
}
@media (max-width: 640px) {
.container {
padding: 1rem;
height: calc(100vh - 100px);
}
.content {
padding: 1.5rem 1rem;
}
.title {
font-size: 1.5rem;
}
.step {
flex-direction: column;
text-align: center;
padding: 1rem;
}
.stepIcon {
align-self: center;
}
.contactMethods {
align-items: center;
}
.dataGrid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.actions {
flex-direction: column-reverse;
}
.backButton,
.resetButton {
width: 100%;
padding: 12px;
}
.additionalActions {
flex-direction: column;
align-items: center;
}
.transcriptButton,
.settingsButton {
width: 100%;
max-width: 200px;
padding: 12px;
}
}

View file

@ -0,0 +1,170 @@
import { IoIosCheckmarkCircle, IoIosMail, IoIosCall, IoIosTime, IoIosRefresh } from 'react-icons/io';
import { useNavigate } from 'react-router-dom';
import sharedStyles from '../PageManager/pages.module.css';
import styles from './SpeechConfirmation.module.css';
import { useLanguage } from '../../contexts/LanguageContext';
interface MandateData {
id: string;
mandate_general: {
company_name: string;
industry: string;
contact_info: {
email: string;
phone: string;
street: string;
postal_code: string;
city: string;
country: string;
};
business_hours: string;
timezone: string;
};
setup_contacts: boolean;
}
interface SpeechConfirmationProps {
onBackToInfo: () => void;
submittedData?: MandateData | null;
onReset?: () => void;
}
function SpeechConfirmation({ onBackToInfo, submittedData, onReset }: SpeechConfirmationProps) {
const { t } = useLanguage();
const navigate = useNavigate();
return (
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.iconContainer}>
<IoIosCheckmarkCircle className={styles.successIcon} />
</div>
<h1 className={styles.title}>{t('speech.confirmation.title')}</h1>
<p className={styles.message}>
{t('speech.confirmation.message')}
</p>
{submittedData && (
<div className={styles.submittedData}>
<h3>{t('speech.confirmation.submitted_data')}</h3>
<div className={styles.dataGrid}>
<div className={styles.dataItem}>
<strong>{t('speech.confirmation.company')}:</strong>
<span>{submittedData.mandate_general.company_name}</span>
</div>
<div className={styles.dataItem}>
<strong>{t('speech.confirmation.industry')}:</strong>
<span>{submittedData.mandate_general.industry}</span>
</div>
<div className={styles.dataItem}>
<strong>{t('speech.confirmation.email')}:</strong>
<span>{submittedData.mandate_general.contact_info.email}</span>
</div>
<div className={styles.dataItem}>
<strong>{t('speech.confirmation.phone')}:</strong>
<span>{submittedData.mandate_general.contact_info.phone}</span>
</div>
<div className={styles.dataItem}>
<strong>{t('speech.confirmation.address')}:</strong>
<span>
{submittedData.mandate_general.contact_info.street}, {submittedData.mandate_general.contact_info.postal_code} {submittedData.mandate_general.contact_info.city}, {submittedData.mandate_general.contact_info.country}
</span>
</div>
<div className={styles.dataItem}>
<strong>{t('speech.confirmation.timezone')}:</strong>
<span>{submittedData.mandate_general.timezone}</span>
</div>
</div>
</div>
)}
<div className={styles.nextSteps}>
<h2>{t('speech.confirmation.next_steps')}</h2>
<div className={styles.step}>
<div className={styles.stepIcon}>
<IoIosMail className={styles.stepIconInner} />
</div>
<div className={styles.stepContent}>
<h3>{t('speech.confirmation.email_confirmation')}</h3>
<p>{t('speech.confirmation.email_confirmation_desc')}</p>
</div>
</div>
<div className={styles.step}>
<div className={styles.stepIcon}>
<IoIosTime className={styles.stepIconInner} />
</div>
<div className={styles.stepContent}>
<h3>{t('speech.confirmation.review_process')}</h3>
<p>{t('speech.confirmation.review_process_desc')}</p>
</div>
</div>
<div className={styles.step}>
<div className={styles.stepIcon}>
<IoIosCall className={styles.stepIconInner} />
</div>
<div className={styles.stepContent}>
<h3>{t('speech.confirmation.setup_call')}</h3>
<p>{t('speech.confirmation.setup_call_desc')}</p>
</div>
</div>
</div>
<div className={styles.contactInfo}>
<h3>{t('speech.confirmation.questions')}</h3>
<p>
{t('speech.confirmation.questions_desc')}
</p>
<div className={styles.contactMethods}>
<div className={styles.contactMethod}>
<IoIosMail className={styles.contactIcon} />
<span>support@spitch.ai</span>
</div>
<div className={styles.contactMethod}>
<IoIosCall className={styles.contactIcon} />
<span>+1 (555) 123-4567</span>
</div>
</div>
</div>
</div>
<div className={styles.actions}>
<button
className={`${sharedStyles.secondaryButton} ${styles.backButton}`}
onClick={onBackToInfo}
>
{t('speech.confirmation.back')}
</button>
{onReset && (
<button
className={`${sharedStyles.primaryButton} ${styles.resetButton}`}
onClick={onReset}
>
<IoIosRefresh className={styles.resetIcon} />
{t('speech.confirmation.reset')}
</button>
)}
</div>
<div className={styles.additionalActions}>
<button
className={`${sharedStyles.primaryButton} ${styles.transcriptButton}`}
onClick={() => navigate('/speech/transcripts')}
>
{t('speech.confirmation.transcript_management')}
</button>
<button
className={`${sharedStyles.secondaryButton} ${styles.settingsButton}`}
onClick={() => navigate('/einstellungen#speech-settings')}
>
{t('speech.confirmation.speech_settings')}
</button>
</div>
</div>
);
}
export default SpeechConfirmation;

View file

@ -0,0 +1,283 @@
.container {
margin: 0 auto;
text-align: center;
width: 100%;
margin: 0;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.content {
flex: 1;
width: 100%;
margin: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 0;
}
.features {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1rem;
flex: 1;
min-height: 0;
}
.feature {
padding: 1rem;
border-radius: 20px;
background: var(--color-bg);
border: 1px solid var(--color-secondary);
transition: all 0.3s ease;
color: var(--color-text);
position: relative;
overflow: hidden;
min-height: 160px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.feature::before {
content: '';
position: absolute;
bottom: 0;
right: 0;
width: 60px;
height: 60px;
background: linear-gradient(135deg, var(--color-secondary), transparent 50%);
opacity: 0.1;
border-radius: 50% 0 0 0;
}
.feature:hover {
transform: translateY(-8px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
background: var(--color-secondary);
color: white;
}
.feature:hover::before {
opacity: 0.2;
}
.featureIconContainer {
width: 50px;
height: 50px;
border-radius: 50%;
background: var(--feature-color, #ff4757);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.75rem;
transition: all 0.3s ease;
}
.feature:hover .featureIconContainer {
background: white;
transform: scale(1.1);
}
.featureAcronym {
font-size: 1.2rem;
font-weight: bold;
color: white;
letter-spacing: 1px;
}
.feature:hover .featureAcronym {
color: var(--feature-color, #ff4757);
}
.featureTitle {
font-size: 1rem;
font-weight: bold;
margin: 0 0 0.5rem 0;
color: var(--color-text);
transition: color 0.3s ease;
}
.feature:hover .featureTitle {
color: white;
}
.featureDescription {
color: var(--color-text-secondary);
margin: 0;
line-height: 1.3;
font-size: 0.8rem;
transition: color 0.3s ease;
flex-grow: 1;
display: flex;
align-items: center;
}
.feature:hover .featureDescription {
color: white;
}
.partnerInfo {
background: var(--color-bg);
padding: 1rem;
border-radius: 20px;
border: 1px solid var(--color-secondary);
text-align: left;
margin-bottom: 0.5rem;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.partnerInfo h2 {
font-size: 1.1rem;
font-weight: bold;
color: var(--color-secondary);
margin: 0 0 0.75rem 0;
text-align: center;
}
.introText {
color: var(--color-text);
line-height: 1.4;
margin-bottom: 1rem;
font-size: 0.85rem;
text-align: center;
font-style: italic;
}
.featureSection {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
.featureItem {
background: var(--color-secondary);
padding: 0.75rem;
border-radius: 10px;
color: white;
}
.featureItem h3 {
font-size: 0.8rem;
font-weight: bold;
margin: 0 0 0.4rem 0;
color: white;
}
.featureItem p {
font-size: 0.7rem;
line-height: 1.3;
margin: 0;
color: rgba(255, 255, 255, 0.9);
}
.partnerLink {
display: flex;
align-items: center;
gap: 0.5rem;
}
.linkIcon {
color: var(--color-secondary);
}
.partnerLink a {
color: var(--color-secondary);
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.partnerLink a:hover {
color: var(--primary-hover, #0056b3);
text-decoration: underline;
}
@media (max-width: 1024px) {
.features {
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.feature {
min-height: 140px;
padding: 0.75rem;
}
}
@media (max-width: 768px) {
.container {
padding: 0.5rem;
}
.features {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.feature {
padding: 0.75rem;
min-height: 120px;
}
.featureIconContainer {
width: 40px;
height: 40px;
}
.featureAcronym {
font-size: 1rem;
}
.featureTitle {
font-size: 0.9rem;
}
.featureDescription {
font-size: 0.75rem;
}
.partnerInfo {
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.partnerInfo h2 {
font-size: 1rem;
}
.introText {
font-size: 0.8rem;
margin-bottom: 0.75rem;
}
.featureSection {
grid-template-columns: 1fr;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.featureItem {
padding: 0.5rem;
}
.featureItem h3 {
font-size: 0.75rem;
}
.featureItem p {
font-size: 0.65rem;
}
}

View file

@ -0,0 +1,112 @@
import { IoIosLink, IoIosCall, IoIosAnalytics, IoIosFingerPrint, IoIosBook, IoIosChatbubbles, IoIosDesktop } from 'react-icons/io';
import styles from './SpeechInfo.module.css';
import { useLanguage } from '../../contexts/LanguageContext';
function SpeechInfo() {
const { t } = useLanguage();
const features = [
{
key: 'va',
icon: IoIosCall,
title: t('speech.info.va'),
description: t('speech.info.va_description'),
color: 'var(--color-secondary)'
},
{
key: 'sa',
icon: IoIosAnalytics,
title: t('speech.info.sa'),
description: t('speech.info.sa_description'),
color: 'var(--color-secondary)'
},
{
key: 'vb',
icon: IoIosFingerPrint,
title: t('speech.info.vb'),
description: t('speech.info.vb_description'),
color: 'var(--color-secondary)'
},
{
key: 'ka',
icon: IoIosBook,
title: t('speech.info.ka'),
description: t('speech.info.ka_description'),
color: 'var(--color-secondary)'
},
{
key: 'cp',
icon: IoIosChatbubbles,
title: t('speech.info.cp'),
description: t('speech.info.cp_description'),
color: 'var(--color-secondary)'
},
{
key: 'aa',
icon: IoIosDesktop,
title: t('speech.info.aa'),
description: t('speech.info.aa_description'),
color: 'var(--color-secondary)'
}
];
return (
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.features}>
{features.map((feature) => (
<div
key={feature.key}
className={`${styles.feature}`}
style={{ '--feature-color': feature.color } as React.CSSProperties}
>
<div className={styles.featureIconContainer}>
<span className={styles.featureAcronym}>{feature.key.toUpperCase()}</span>
</div>
<h3 className={styles.featureTitle}>{feature.title}</h3>
<p className={styles.featureDescription}>{feature.description}</p>
</div>
))}
</div>
<div className={styles.partnerInfo}>
<h2>{t('speech.info.about')}</h2>
<p className={styles.introText}>
{t('speech.info.about_intro')}
</p>
<div className={styles.featureSection}>
<div className={styles.featureItem}>
<h3>🎯 {t('speech.info.workflow_title')}</h3>
<p>{t('speech.info.workflow_description')}</p>
</div>
<div className={styles.featureItem}>
<h3>🤖 {t('speech.info.ai_title')}</h3>
<p>{t('speech.info.ai_description')}</p>
</div>
<div className={styles.featureItem}>
<h3>🔄 {t('speech.info.sync_title')}</h3>
<p>{t('speech.info.sync_description')}</p>
</div>
<div className={styles.featureItem}>
<h3>💰 {t('speech.info.cost_title')}</h3>
<p>{t('speech.info.cost_description')}</p>
</div>
</div>
<div className={styles.partnerLink}>
<IoIosLink className={styles.linkIcon} />
<a href="https://spitch.ai" target="_blank" rel="noopener noreferrer">
{t('speech.info.about_link')}
</a>
</div>
</div>
</div>
</div>
);
}
export default SpeechInfo;

View file

@ -0,0 +1,225 @@
.container {
width: 100%;
max-width: 100%;
margin: 0;
padding: 0;
}
.header {
margin-bottom: 2rem;
}
.title {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text);
margin: 0 0 0.5rem 0;
}
.description {
font-size: 1rem;
color: var(--color-text-secondary);
margin: 0;
line-height: 1.5;
}
.loading {
text-align: center;
padding: 2rem;
color: var(--color-text);
font-size: 1rem;
}
.noData {
text-align: center;
padding: 2rem;
background: var(--color-bg);
border-radius: 20px;
border: 1px solid var(--color-secondary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.noData p {
margin: 0 0 1.5rem 0;
color: var(--color-text);
font-size: 1rem;
}
.message {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
font-size: 0.9rem;
font-weight: 500;
}
.successMessage {
background-color: rgba(34, 197, 94, 0.1);
color: #16a34a;
border: 1px solid rgba(34, 197, 94, 0.2);
}
.errorMessage {
background-color: rgba(239, 68, 68, 0.1);
color: #dc2626;
border: 1px solid rgba(239, 68, 68, 0.2);
}
.form {
display: flex;
flex-direction: column;
gap: 2rem;
}
.section {
background: var(--color-bg);
border-radius: 20px;
border: 1px solid var(--color-secondary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
padding: 1.5rem;
}
.sectionHeader {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--color-secondary);
}
.sectionIcon {
font-size: 1.25rem;
color: var(--color-secondary);
}
.sectionTitle {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-text);
margin: 0;
}
.formRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.formRow:last-child {
margin-bottom: 0;
}
.formField {
display: flex;
flex-direction: column;
}
.inputContainer {
position: relative;
width: 100%;
}
.formInput,
.formSelect {
width: 100%;
padding: 1rem 0.75rem 0.5rem 0.75rem;
border: 2px solid var(--color-border);
border-radius: 8px;
font-size: 1rem;
font-family: var(--font-family);
background: var(--color-bg);
color: var(--color-text);
transition: all 0.2s ease;
outline: none;
}
.formInput:focus,
.formSelect:focus {
border-color: var(--color-secondary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.formInput:invalid:not(:focus) {
border-color: #ef4444;
}
.formSelect {
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
}
.floatingLabel {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: var(--color-bg);
padding: 0 0.25rem;
color: var(--color-text-secondary);
font-size: 1rem;
font-family: var(--font-family);
pointer-events: none;
transition: all 0.2s ease;
z-index: 1;
}
.floatingLabelActive {
top: 0.25rem;
transform: translateY(0);
font-size: 0.75rem;
color: var(--color-secondary);
font-weight: 500;
}
.actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
align-items: center;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
}
.resetIcon {
margin-right: 0.5rem;
font-size: 1rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.formRow {
grid-template-columns: 1fr;
gap: 1rem;
}
.actions {
flex-direction: column;
align-items: stretch;
}
.actions button {
width: 100%;
}
}
@media (max-width: 640px) {
.section {
padding: 1rem;
}
.sectionHeader {
margin-bottom: 1rem;
padding-bottom: 0.5rem;
}
.formRow {
gap: 0.75rem;
margin-bottom: 1rem;
}
}

View file

@ -0,0 +1,482 @@
import React, { useState, useEffect } from 'react';
import { IoIosBusiness, IoIosContact, IoIosTime, IoIosRefresh } from 'react-icons/io';
import sharedStyles from '../PageManager/pages.module.css';
import styles from './SpeechSettings.module.css';
import { useLanguage } from '../../contexts/LanguageContext';
interface MandateData {
id: string;
mandate_general: {
company_name: string;
industry: string;
contact_info: {
email: string;
phone: string;
street: string;
postal_code: string;
city: string;
country: string;
};
business_hours: string;
timezone: string;
};
setup_contacts: boolean;
}
interface SpeechSettingsProps {
onDataUpdate?: (data: MandateData) => void;
}
function SpeechSettings({ onDataUpdate }: SpeechSettingsProps) {
const { t } = useLanguage();
const [formData, setFormData] = useState<MandateData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const [focusedFields, setFocusedFields] = useState<Set<string>>(new Set());
// Load data from localStorage on component mount
useEffect(() => {
const loadSpeechData = () => {
try {
const savedData = localStorage.getItem('speechSignUpData');
const timestamp = localStorage.getItem('speechSignUpTimestamp');
if (savedData && timestamp) {
const parsedData = JSON.parse(savedData);
const savedTime = parseInt(timestamp);
const now = Date.now();
const twentyFourHours = 24 * 60 * 60 * 1000;
// Check if data is still valid (within 24 hours)
if (now - savedTime < twentyFourHours) {
setFormData(parsedData);
} else {
// Data expired, clear it
localStorage.removeItem('speechSignUpData');
localStorage.removeItem('speechSignUpTimestamp');
}
}
} catch (error) {
console.error('Error loading speech data:', error);
} finally {
setIsLoading(false);
}
};
loadSpeechData();
}, []);
const handleInputChange = (field: string, value: string) => {
if (!formData) return;
const newData = { ...formData };
const fieldParts = field.split('.');
if (fieldParts.length === 2) {
// Handle nested fields like mandate_general.company_name
const [parent, child] = fieldParts;
if (parent === 'mandate_general' && child in newData.mandate_general) {
(newData.mandate_general as any)[child] = value;
}
} else if (fieldParts.length === 3) {
// Handle deeply nested fields like mandate_general.contact_info.email
const [parent, child, grandchild] = fieldParts;
if (parent === 'mandate_general' && child === 'contact_info' && grandchild in newData.mandate_general.contact_info) {
(newData.mandate_general.contact_info as any)[grandchild] = value;
}
} else if (field === 'setup_contacts') {
newData.setup_contacts = value === 'true';
}
setFormData(newData);
setSaveMessage(null);
};
const handleFocus = (field: string) => {
setFocusedFields(prev => new Set(prev).add(field));
};
const handleBlur = (field: string) => {
setFocusedFields(prev => {
const newSet = new Set(prev);
newSet.delete(field);
return newSet;
});
};
const handleSave = async () => {
if (!formData) return;
setIsSaving(true);
try {
// Save to localStorage
localStorage.setItem('speechSignUpData', JSON.stringify(formData));
localStorage.setItem('speechSignUpTimestamp', Date.now().toString());
// Dispatch event to notify other components
window.dispatchEvent(new CustomEvent('speechSignUpChanged'));
setSaveMessage({ type: 'success', text: t('speech.settings.save_success') });
// Notify parent component if callback provided
if (onDataUpdate) {
onDataUpdate(formData);
}
// Clear message after 3 seconds
setTimeout(() => setSaveMessage(null), 3000);
} catch (error) {
console.error('Error saving speech settings:', error);
setSaveMessage({ type: 'error', text: t('speech.settings.save_error') });
} finally {
setIsSaving(false);
}
};
const handleReset = () => {
if (window.confirm(t('speech.settings.reset_confirm'))) {
localStorage.removeItem('speechSignUpData');
localStorage.removeItem('speechSignUpTimestamp');
window.dispatchEvent(new CustomEvent('speechSignUpChanged'));
setFormData(null);
setSaveMessage({ type: 'success', text: t('speech.settings.reset_success') });
setTimeout(() => setSaveMessage(null), 3000);
}
};
if (isLoading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
{t('common.loading')}
</div>
</div>
);
}
if (!formData) {
return (
<div className={styles.container}>
<div className={styles.noData}>
<p>{t('speech.settings.no_data')}</p>
<button
className={sharedStyles.primaryButton}
onClick={() => window.location.href = '/speech'}
>
{t('speech.settings.sign_up_now')}
</button>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<h2 className={styles.title}>{t('speech.settings.title')}</h2>
<p className={styles.description}>{t('speech.settings.description')}</p>
</div>
{saveMessage && (
<div className={`${styles.message} ${saveMessage.type === 'success' ? styles.successMessage : styles.errorMessage}`}>
{saveMessage.text}
</div>
)}
<div className={styles.form}>
{/* Company Information Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<IoIosBusiness className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>{t('speech.settings.company_info')}</h3>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="company_name"
className={styles.formInput}
value={formData.mandate_general.company_name}
onChange={(e) => handleInputChange('mandate_general.company_name', e.target.value)}
onFocus={() => handleFocus('company_name')}
onBlur={() => handleBlur('company_name')}
required
/>
<label
htmlFor="company_name"
className={`${styles.floatingLabel} ${formData.mandate_general.company_name || focusedFields.has('company_name') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.company_name')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="industry"
className={styles.formInput}
value={formData.mandate_general.industry}
onChange={(e) => handleInputChange('mandate_general.industry', e.target.value)}
onFocus={() => handleFocus('industry')}
onBlur={() => handleBlur('industry')}
required
/>
<label
htmlFor="industry"
className={`${styles.floatingLabel} ${formData.mandate_general.industry || focusedFields.has('industry') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.industry')} *
</label>
</div>
</div>
</div>
</div>
{/* Contact Information Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<IoIosContact className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>{t('speech.settings.contact_info')}</h3>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="email"
id="email"
className={styles.formInput}
value={formData.mandate_general.contact_info.email}
onChange={(e) => handleInputChange('mandate_general.contact_info.email', e.target.value)}
onFocus={() => handleFocus('email')}
onBlur={() => handleBlur('email')}
required
/>
<label
htmlFor="email"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.email || focusedFields.has('email') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.email')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="tel"
id="phone"
className={styles.formInput}
value={formData.mandate_general.contact_info.phone}
onChange={(e) => handleInputChange('mandate_general.contact_info.phone', e.target.value)}
onFocus={() => handleFocus('phone')}
onBlur={() => handleBlur('phone')}
required
/>
<label
htmlFor="phone"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.phone || focusedFields.has('phone') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.phone')} *
</label>
</div>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="street"
className={styles.formInput}
value={formData.mandate_general.contact_info.street}
onChange={(e) => handleInputChange('mandate_general.contact_info.street', e.target.value)}
onFocus={() => handleFocus('street')}
onBlur={() => handleBlur('street')}
required
/>
<label
htmlFor="street"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.street || focusedFields.has('street') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.street')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="postal_code"
className={styles.formInput}
value={formData.mandate_general.contact_info.postal_code}
onChange={(e) => handleInputChange('mandate_general.contact_info.postal_code', e.target.value)}
onFocus={() => handleFocus('postal_code')}
onBlur={() => handleBlur('postal_code')}
required
/>
<label
htmlFor="postal_code"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.postal_code || focusedFields.has('postal_code') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.postal_code')} *
</label>
</div>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="city"
className={styles.formInput}
value={formData.mandate_general.contact_info.city}
onChange={(e) => handleInputChange('mandate_general.contact_info.city', e.target.value)}
onFocus={() => handleFocus('city')}
onBlur={() => handleBlur('city')}
required
/>
<label
htmlFor="city"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.city || focusedFields.has('city') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.city')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="country"
className={styles.formInput}
value={formData.mandate_general.contact_info.country}
onChange={(e) => handleInputChange('mandate_general.contact_info.country', e.target.value)}
onFocus={() => handleFocus('country')}
onBlur={() => handleBlur('country')}
required
/>
<label
htmlFor="country"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.country || focusedFields.has('country') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.country')} *
</label>
</div>
</div>
</div>
</div>
{/* Business Hours Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<IoIosTime className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>{t('speech.settings.business_hours')}</h3>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="business_hours"
className={styles.formInput}
value={formData.mandate_general.business_hours}
onChange={(e) => handleInputChange('mandate_general.business_hours', e.target.value)}
onFocus={() => handleFocus('business_hours')}
onBlur={() => handleBlur('business_hours')}
required
/>
<label
htmlFor="business_hours"
className={`${styles.floatingLabel} ${formData.mandate_general.business_hours || focusedFields.has('business_hours') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.business_hours')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<select
id="timezone"
className={styles.formSelect}
value={formData.mandate_general.timezone}
onChange={(e) => handleInputChange('mandate_general.timezone', e.target.value)}
onFocus={() => handleFocus('timezone')}
onBlur={() => handleBlur('timezone')}
required
>
<option value="">{t('speech.signup.select_timezone')}</option>
<option value="UTC-12">UTC-12 (Baker Island)</option>
<option value="UTC-11">UTC-11 (American Samoa)</option>
<option value="UTC-10">UTC-10 (Hawaii)</option>
<option value="UTC-9">UTC-9 (Alaska)</option>
<option value="UTC-8">UTC-8 (Pacific Time)</option>
<option value="UTC-7">UTC-7 (Mountain Time)</option>
<option value="UTC-6">UTC-6 (Central Time)</option>
<option value="UTC-5">UTC-5 (Eastern Time)</option>
<option value="UTC-4">UTC-4 (Atlantic Time)</option>
<option value="UTC-3">UTC-3 (Brazil)</option>
<option value="UTC-2">UTC-2 (Mid-Atlantic)</option>
<option value="UTC-1">UTC-1 (Azores)</option>
<option value="UTC+0">UTC+0 (Greenwich Mean Time)</option>
<option value="UTC+1">UTC+1 (Central European Time)</option>
<option value="UTC+2">UTC+2 (Eastern European Time)</option>
<option value="UTC+3">UTC+3 (Moscow Time)</option>
<option value="UTC+4">UTC+4 (Gulf Standard Time)</option>
<option value="UTC+5">UTC+5 (Pakistan Standard Time)</option>
<option value="UTC+6">UTC+6 (Bangladesh Standard Time)</option>
<option value="UTC+7">UTC+7 (Indochina Time)</option>
<option value="UTC+8">UTC+8 (China Standard Time)</option>
<option value="UTC+9">UTC+9 (Japan Standard Time)</option>
<option value="UTC+10">UTC+10 (Australian Eastern Time)</option>
<option value="UTC+11">UTC+11 (Solomon Islands)</option>
<option value="UTC+12">UTC+12 (New Zealand)</option>
</select>
<label
htmlFor="timezone"
className={`${styles.floatingLabel} ${formData.mandate_general.timezone || focusedFields.has('timezone') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.timezone')} *
</label>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className={styles.actions}>
<button
className={sharedStyles.secondaryButton}
onClick={handleReset}
disabled={isSaving}
>
<IoIosRefresh className={styles.resetIcon} />
{t('speech.settings.reset')}
</button>
<button
className={sharedStyles.primaryButton}
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? t('speech.settings.saving') : t('speech.settings.save')}
</button>
</div>
</div>
</div>
);
}
export default SpeechSettings;

View file

@ -0,0 +1,384 @@
.container {
max-width: 100%;
margin: 0;
height: calc(100vh - 120px);
overflow-y: auto;
}
.header {
margin-bottom: 1.5rem;
position: relative;
}
.backButton {
display: flex;
align-items: center;
gap: 0.5rem;
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
font-size: 1rem;
padding: 0.5rem 0;
transition: color 0.2s ease;
margin-bottom: 1rem;
}
.backButton:hover {
color: var(--primary-color, #007bff);
}
.backIcon {
font-size: 1.2rem;
}
.title {
font-size: 2rem;
font-weight: bold;
margin: 0 0 0.5rem 0;
color: var(--color-text);
}
.subtitle {
color: var(--color-text);
margin: 0;
line-height: 1.5;
}
.form {
display: flex;
flex-direction: column;
gap: 1.25rem;
height: 100%;
}
.section {
background: var(--color-bg);
border: 1px solid var(--color-secondary);
border-radius: 25px;
padding: 1.25rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.sectionTitle {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.2rem;
font-weight: bold;
color: var(--color-secondary);
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--color-secondary);
}
.sectionIcon {
font-size: 1.3rem;
}
.formGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.inputGroupFull {
grid-column: 1 / -1;
}
.inputGroup {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 20px;
}
.label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
color: var(--color-text);
font-size: 14px;
margin-bottom: 6px;
}
.labelIcon {
color: var(--color-primary);
font-size: 1rem;
}
/* Floating label container */
.floatingLabelInput {
position: relative;
margin-bottom: 20px;
}
.input {
width: 100%;
padding: 12px 12px 8px 12px;
border: 1px solid var(--color-primary);
border-radius: 25px;
font-size: 14px;
transition: all 0.2s ease;
background-color: var(--color-bg);
box-sizing: border-box;
color: var(--color-text);
}
.input:focus {
outline: none;
border-color: var(--color-secondary);
}
.inputError {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.inputError:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
/* Select styling to match input fields */
select.input {
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6,9 12,15 18,9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
padding-right: 40px;
cursor: pointer;
}
select.input:focus {
outline: none;
border-color: var(--color-secondary);
}
select.input:hover {
border-color: var(--color-secondary);
}
/* Floating label styles */
.floatingLabel {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--color-primary);
opacity: 0.7;
font-size: 14px;
transition: all 0.2s ease;
pointer-events: none;
z-index: 1;
}
.focusedLabel {
position: absolute;
left: 12px;
top: -8px;
transform: translateY(0);
color: var(--color-primary);
opacity: 1;
font-size: 12px;
font-weight: 500;
background-color: var(--color-bg);
padding: 0 4px;
transition: all 0.2s ease;
pointer-events: none;
z-index: 2;
}
.activeFocusedLabel {
position: absolute;
left: 12px;
top: -8px;
transform: translateY(0);
color: var(--color-secondary);
opacity: 1;
font-size: 12px;
font-weight: 500;
background-color: var(--color-bg);
padding: 0 4px;
transition: all 0.2s ease;
pointer-events: none;
z-index: 2;
}
.textarea {
width: 100%;
padding: 12px 12px 8px 12px;
border: 1px solid var(--color-primary);
border-radius: 25px;
font-size: 14px;
transition: all 0.2s ease;
background-color: var(--color-bg);
box-sizing: border-box;
color: var(--color-text);
font-family: inherit;
line-height: 1.5;
overflow-y: auto;
resize: vertical;
min-height: 4em;
max-height: 8em;
}
.textarea:focus {
outline: none;
border-color: var(--color-secondary);
}
.textarea.inputError {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.errorText {
color: #ef4444;
font-size: 12px;
margin-top: 4px;
display: block;
}
.contactsSection {
text-align: center;
padding: 1rem 0;
}
.contactsDescription {
color: var(--color-text);
margin-bottom: 1.5rem;
line-height: 1.5;
}
.contactsActions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.skipButton {
padding: 8px 16px;
border: 1px solid var(--color-primary);
background-color: var(--color-bg);
color: var(--color-text);
border-radius: 25px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.skipButton:hover {
background-color: var(--color-primary-hover);
border-color: var(--color-primary);
color: #181818;
}
.setupButton {
padding: 8px 16px;
border: none;
background-color: var(--color-secondary);
color: var(--color-bg);
border-radius: 25px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.setupButton:hover {
background-color: var(--color-secondary-hover);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--color-primary);
}
.cancelButton {
padding: 8px 16px;
border: 1px solid var(--color-primary);
background-color: var(--color-bg);
color: var(--color-text);
border-radius: 25px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.cancelButton:hover {
background-color: var(--color-primary-hover);
border-color: var(--color-primary);
color: #181818;
}
.submitButton {
padding: 8px 16px;
border: none;
background-color: var(--color-secondary);
color: var(--color-bg);
border-radius: 25px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.submitButton:hover {
background-color: var(--color-secondary-hover);
}
.submitButton:disabled {
background-color: #9ca3af;
cursor: not-allowed;
}
@media (max-width: 640px) {
.container {
padding: 1rem;
height: calc(100vh - 100px);
}
.formGrid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.section {
padding: 1rem;
}
.sectionTitle {
font-size: 1.1rem;
}
.contactsActions {
flex-direction: column;
align-items: center;
}
.skipButton,
.setupButton {
width: 100%;
max-width: 200px;
}
.actions {
flex-direction: column-reverse;
}
.cancelButton,
.submitButton {
width: 100%;
padding: 12px;
}
}

View file

@ -0,0 +1,342 @@
import { useState } from 'react';
import { IoIosArrowBack, IoIosBusiness, IoIosPeople } from 'react-icons/io';
import sharedStyles from '../PageManager/pages.module.css';
import styles from './SpeechSignUp.module.css';
import { useLanguage } from '../../contexts/LanguageContext';
interface SpeechSignUpProps {
onBack: () => void;
onSubmit: (data: MandateData) => void;
}
interface MandateData {
id: string;
mandate_general: {
company_name: string;
industry: string;
contact_info: {
email: string;
phone: string;
street: string;
postal_code: string;
city: string;
country: string;
};
business_hours: string;
timezone: string;
};
setup_contacts: boolean;
}
function SpeechSignUp({ onBack, onSubmit }: SpeechSignUpProps) {
const { t } = useLanguage();
const [formData, setFormData] = useState<MandateData>({
id: crypto.randomUUID(),
mandate_general: {
company_name: '',
industry: '',
contact_info: {
email: '',
phone: '',
street: '',
postal_code: '',
city: '',
country: ''
},
business_hours: '9:00-17:00',
timezone: 'Europe/Zurich'
},
setup_contacts: false
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [focusedFields, setFocusedFields] = useState<Set<string>>(new Set());
const handleInputChange = (field: string, value: string) => {
if (field.startsWith('contact_info.')) {
const contactField = field.split('.')[1] as keyof typeof formData.mandate_general.contact_info;
setFormData(prev => ({
...prev,
mandate_general: {
...prev.mandate_general,
contact_info: {
...prev.mandate_general.contact_info,
[contactField]: value
}
}
}));
} else if (field.startsWith('mandate_general.')) {
const generalField = field.split('.')[1] as keyof typeof formData.mandate_general;
setFormData(prev => ({
...prev,
mandate_general: {
...prev.mandate_general,
[generalField]: value
}
}));
} else {
setFormData(prev => ({ ...prev, [field]: value }));
}
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const handleFocus = (field: string) => {
setFocusedFields(prev => new Set(prev).add(field));
};
const handleBlur = (field: string) => {
setFocusedFields(prev => {
const newSet = new Set(prev);
newSet.delete(field);
return newSet;
});
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.mandate_general.company_name.trim()) newErrors['mandate_general.company_name'] = t('speech.signup.company_required');
if (!formData.mandate_general.industry.trim()) newErrors['mandate_general.industry'] = t('speech.signup.industry_required');
if (!formData.mandate_general.contact_info.email.trim()) {
newErrors['contact_info.email'] = t('speech.signup.email_required');
} else if (!/\S+@\S+\.\S+/.test(formData.mandate_general.contact_info.email)) {
newErrors['contact_info.email'] = t('speech.signup.email_invalid');
}
if (!formData.mandate_general.contact_info.phone.trim()) newErrors['contact_info.phone'] = t('speech.signup.phone_required');
if (!formData.mandate_general.contact_info.street.trim()) newErrors['contact_info.street'] = t('speech.signup.street_required');
if (!formData.mandate_general.contact_info.postal_code.trim()) newErrors['contact_info.postal_code'] = t('speech.signup.postal_code_required');
if (!formData.mandate_general.contact_info.city.trim()) newErrors['contact_info.city'] = t('speech.signup.city_required');
if (!formData.mandate_general.contact_info.country.trim()) newErrors['contact_info.country'] = t('speech.signup.country_required');
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validateForm()) {
// Save to localStorage for session persistence
try {
localStorage.setItem('speechSignUpData', JSON.stringify(formData));
localStorage.setItem('speechSignUpTimestamp', Date.now().toString());
console.log('Sign-up data saved to localStorage:', formData);
// Dispatch custom event to refresh sidebar
window.dispatchEvent(new CustomEvent('speechSignUpChanged'));
} catch (error) {
console.error('Failed to save to localStorage:', error);
}
onSubmit(formData);
}
};
const renderFloatingInput = (
field: string,
value: string,
placeholder: string,
type: string = 'text',
icon?: React.ReactNode
) => {
const isFocused = focusedFields.has(field);
const hasValue = value.trim() !== '';
const hasError = errors[field];
return (
<div className={styles.floatingLabelInput}>
<input
type={type}
value={value}
onChange={(e) => handleInputChange(field, e.target.value)}
onFocus={() => handleFocus(field)}
onBlur={() => handleBlur(field)}
className={`${styles.input} ${hasError ? styles.inputError : ''}`}
placeholder=""
/>
<label className={
isFocused || hasValue
? hasError
? styles.focusedLabel
: styles.activeFocusedLabel
: styles.floatingLabel
}>
{icon}
{placeholder}
</label>
{hasError && <span className={styles.errorText}>{hasError}</span>}
</div>
);
};
return (
<div className={styles.container}>
<div className={styles.header}>
<button
className={styles.backButton}
onClick={onBack}
type="button"
>
<IoIosArrowBack className={styles.backIcon} />
{t('speech.signup.back')}
</button>
<h1 className={styles.title}>{t('speech.signup.title')}</h1>
<p className={styles.subtitle}>
{t('speech.signup.subtitle')}
</p>
</div>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<IoIosBusiness className={styles.sectionIcon} />
{t('speech.signup.company_info')}
</h2>
<div className={styles.formGrid}>
{renderFloatingInput(
'mandate_general.company_name',
formData.mandate_general.company_name,
`${t('speech.signup.company_name')} *`
)}
{renderFloatingInput(
'mandate_general.industry',
formData.mandate_general.industry,
`${t('speech.signup.industry')} *`
)}
{renderFloatingInput(
'mandate_general.business_hours',
formData.mandate_general.business_hours,
`${t('speech.signup.business_hours')}`,
'text',
)}
<div className={styles.floatingLabelInput}>
<select
value={formData.mandate_general.timezone}
onChange={(e) => handleInputChange('mandate_general.timezone', e.target.value)}
onFocus={() => handleFocus('mandate_general.timezone')}
onBlur={() => handleBlur('mandate_general.timezone')}
className={styles.input}
>
<option value="Europe/Zurich">Europe/Zurich</option>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/Paris">Europe/Paris</option>
<option value="Europe/London">Europe/London</option>
<option value="America/New_York">America/New_York</option>
</select>
<label className={
focusedFields.has('mandate_general.timezone') || formData.mandate_general.timezone.trim() !== ''
? styles.activeFocusedLabel
: styles.floatingLabel
}>
{t('speech.signup.timezone')}
</label>
</div>
</div>
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
{t('speech.signup.contact_info')}
</h2>
<div className={styles.formGrid}>
{renderFloatingInput(
'contact_info.email',
formData.mandate_general.contact_info.email,
`${t('speech.signup.email')} *`,
'email'
)}
{renderFloatingInput(
'contact_info.phone',
formData.mandate_general.contact_info.phone,
`${t('speech.signup.phone')} *`,
'tel',
)}
{renderFloatingInput(
'contact_info.street',
formData.mandate_general.contact_info.street,
`${t('speech.signup.street')} *`
)}
{renderFloatingInput(
'contact_info.postal_code',
formData.mandate_general.contact_info.postal_code,
`${t('speech.signup.postal_code')} *`
)}
{renderFloatingInput(
'contact_info.city',
formData.mandate_general.contact_info.city,
`${t('speech.signup.city')} *`
)}
{renderFloatingInput(
'contact_info.country',
formData.mandate_general.contact_info.country,
`${t('speech.signup.country')} *`
)}
</div>
</div>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>
<IoIosPeople className={styles.sectionIcon} />
{t('speech.signup.contacts_setup')}
</h2>
<div className={styles.contactsSection}>
<p className={styles.contactsDescription}>
{t('speech.signup.contacts_description')}
</p>
<div className={styles.contactsActions}>
<button
type="button"
className={`${sharedStyles.secondaryButton} ${styles.skipButton}`}
onClick={() => handleInputChange('setup_contacts', 'false')}
>
{t('speech.signup.skip_for_now')}
</button>
<button
type="button"
className={`${sharedStyles.primaryButton} ${styles.setupButton}`}
onClick={() => handleInputChange('setup_contacts', 'true')}
>
{t('speech.signup.setup_contacts')}
</button>
</div>
</div>
</div>
<div className={styles.actions}>
<button
type="button"
className={`${sharedStyles.secondaryButton} ${styles.cancelButton}`}
onClick={onBack}
>
{t('speech.signup.cancel')}
</button>
<button
type="submit"
className={`${sharedStyles.primaryButton} ${styles.submitButton}`}
>
{t('speech.signup.submit')}
</button>
</div>
</form>
</div>
);
}
export default SpeechSignUp;

View file

@ -0,0 +1,4 @@
export { default as SpeechInfo } from './SpeechInfo';
export { default as SpeechSignUp } from './SpeechSignUp';
export { default as SpeechConfirmation } from './SpeechConfirmation';
export { default as SpeechSettings } from './SpeechSettings';

View file

@ -38,6 +38,7 @@ function WorkflowsTable({ className = '' }: WorkflowsTableProps) {
actions={logic.actions}
onDelete={logic.handleDeleteSingle}
onDeleteMultiple={logic.handleDeleteMultiple}
onRefresh={logic.refetch}
className={styles.workflowsFormGenerator}
onRowClick={(workflow: any) => {
// TODO: Navigate to workflow detail view

View file

@ -193,7 +193,18 @@ export function useWorkflowsLogic(): WorkflowsLogicReturn {
console.warn('Invalid startedAt date:', value);
return '-';
}
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
} catch (error) {
console.warn('Error parsing startedAt date:', value, error);
return '-';
@ -220,7 +231,18 @@ export function useWorkflowsLogic(): WorkflowsLogicReturn {
console.warn('Invalid lastActivity date:', value);
return '-';
}
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const timezoneOffset = date.getTimezoneOffset();
const offsetHours = Math.floor(Math.abs(timezoneOffset) / 60);
const offsetMinutes = Math.abs(timezoneOffset) % 60;
const offsetSign = timezoneOffset <= 0 ? '+' : '-';
const timezone = `GMT${offsetSign}${offsetHours}${offsetMinutes > 0 ? ':' + offsetMinutes.toString().padStart(2, '0') : ''}`;
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${timezone}`;
} catch (error) {
console.warn('Error parsing lastActivity date:', value, error);
return '-';

View file

@ -0,0 +1,290 @@
/* Speech Settings Form - matches userInfoForm styling */
.speechSettingsForm {
display: flex;
flex-direction: column;
gap: 20px;
padding: 20px;
background: var(--color-bg);
border-radius: 25px;
border: 1px solid var(--color-primary);
margin-bottom: 20px;
}
.settingLabel {
font-size: 1rem;
font-weight: 500;
color: var(--color-text);
font-family: var(--font-family);
}
.settingDescription {
font-size: 0.875rem;
color: var(--color-primary);
font-family: var(--font-family);
}
.loading {
text-align: center;
padding: 2rem;
color: var(--color-text);
font-size: 1rem;
}
.noData {
text-align: center;
padding: 2rem;
background: var(--color-bg);
border-radius: 20px;
border: 1px solid var(--color-secondary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.noData p {
margin: 0 0 1.5rem 0;
color: var(--color-text);
font-size: 1rem;
}
.signUpButton {
padding: 12px 24px;
border-radius: 25px;
border: none;
background: var(--color-secondary);
color: white;
cursor: pointer;
transition: all 0.3s ease;
font-family: var(--font-family);
font-size: 0.875rem;
font-weight: 500;
min-width: 120px;
}
.signUpButton:hover:not(:disabled) {
background: var(--color-secondary);
border-color: var(--color-secondary);
box-shadow: 0 4px 12px rgba(63, 81, 181, 0.3);
transform: translateY(-2px);
}
.updateMessage {
padding: 12px 16px;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 10px;
}
.successMessage {
background-color: rgba(34, 197, 94, 0.1);
color: #16a34a;
border: 1px solid rgba(34, 197, 94, 0.2);
}
.errorMessage {
background-color: rgba(239, 68, 68, 0.1);
color: #dc2626;
border: 1px solid rgba(239, 68, 68, 0.2);
}
/* Section styling */
.section {
display: flex;
flex-direction: column;
gap: 15px;
padding: 15px;
background: var(--color-bg);
border-radius: 15px;
border: 1px solid var(--color-secondary);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.sectionHeader {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--color-secondary);
}
.sectionIcon {
font-size: 1.1rem;
color: var(--color-secondary);
}
.sectionTitle {
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
margin: 0;
font-family: var(--font-family);
}
/* Form styling - matches userInfoForm */
.formRow {
display: flex;
gap: 20px;
flex-wrap: wrap;
}
.formField {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-width: 250px;
}
.fieldLabel {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
font-family: var(--font-family);
}
.formInput,
.formSelect {
padding: 12px 16px;
border-radius: 25px;
border: 1px solid var(--color-primary);
background: var(--color-bg);
color: var(--color-text);
font-family: var(--font-family);
font-size: 0.875rem;
transition: all 0.3s ease;
outline: none;
}
.formInput:focus,
.formSelect:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(63, 81, 181, 0.1);
}
.formInput:hover,
.formSelect:hover {
border-color: var(--color-secondary);
}
.formSelect {
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
padding-right: 2.5rem;
}
.formSelect option {
background: var(--color-bg);
color: var(--color-text);
padding: 10px;
}
/* Actions styling - matches userInfoForm */
.formActions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 10px;
}
.saveButton {
padding: 12px 24px;
border-radius: 25px;
border: none;
background: var(--color-secondary);
color: white;
cursor: pointer;
transition: all 0.3s ease;
font-family: var(--font-family);
font-size: 0.875rem;
font-weight: 500;
min-width: 120px;
}
.saveButton:hover:not(:disabled) {
background: var(--color-secondary);
border-color: var(--color-secondary);
box-shadow: 0 4px 12px rgba(63, 81, 181, 0.3);
transform: translateY(-2px);
}
.saveButton:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.resetButton {
padding: 12px 24px;
border-radius: 25px;
border: 1px solid var(--color-primary);
background: var(--color-bg);
color: var(--color-text);
cursor: pointer;
transition: all 0.3s ease;
font-family: var(--font-family);
font-size: 0.875rem;
font-weight: 500;
min-width: 120px;
display: flex;
align-items: center;
gap: 8px;
}
.resetButton:hover:not(:disabled) {
background: var(--color-primary);
color: white;
transform: translateY(-2px);
}
.resetButton:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.resetIcon {
font-size: 1rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.formRow {
flex-direction: column;
gap: 15px;
}
.formField {
min-width: unset;
}
.formActions {
justify-content: center;
flex-direction: column;
}
.saveButton,
.resetButton {
width: 100%;
}
}
@media (max-width: 640px) {
.speechSettingsForm {
padding: 15px;
}
.section {
padding: 12px;
}
.sectionHeader {
gap: 6px;
padding-bottom: 6px;
}
.formRow {
gap: 12px;
}
}

View file

@ -0,0 +1,419 @@
import React, { useState, useEffect } from 'react';
import { IoIosBusiness, IoIosContact, IoIosTime, IoIosRefresh } from 'react-icons/io';
import { useNavigate } from 'react-router-dom';
import styles from './settingsSpeech.module.css';
import { useLanguage } from '../../contexts/LanguageContext';
interface MandateData {
id: string;
mandate_general: {
company_name: string;
industry: string;
contact_info: {
email: string;
phone: string;
street: string;
postal_code: string;
city: string;
country: string;
};
business_hours: string;
timezone: string;
};
setup_contacts: boolean;
}
interface SettingsSpeechProps {
onDataUpdate?: (data: MandateData) => void;
}
function SettingsSpeech({ onDataUpdate }: SettingsSpeechProps) {
const { t } = useLanguage();
const navigate = useNavigate();
const [formData, setFormData] = useState<MandateData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const [focusedFields, setFocusedFields] = useState<Set<string>>(new Set());
// Load data from localStorage on component mount
useEffect(() => {
const loadSpeechData = () => {
try {
const savedData = localStorage.getItem('speechSignUpData');
const timestamp = localStorage.getItem('speechSignUpTimestamp');
if (savedData && timestamp) {
const parsedData = JSON.parse(savedData);
const savedTime = parseInt(timestamp);
const now = Date.now();
const twentyFourHours = 24 * 60 * 60 * 1000;
// Check if data is still valid (within 24 hours)
if (now - savedTime < twentyFourHours) {
setFormData(parsedData);
} else {
// Data expired, clear it
localStorage.removeItem('speechSignUpData');
localStorage.removeItem('speechSignUpTimestamp');
}
}
} catch (error) {
console.error('Error loading speech data:', error);
} finally {
setIsLoading(false);
}
};
loadSpeechData();
}, []);
const handleInputChange = (field: string, value: string) => {
if (!formData) return;
const newData = { ...formData };
const fieldParts = field.split('.');
if (fieldParts.length === 2) {
// Handle nested fields like mandate_general.company_name
const [parent, child] = fieldParts;
if (parent === 'mandate_general' && child in newData.mandate_general) {
(newData.mandate_general as any)[child] = value;
}
} else if (fieldParts.length === 3) {
// Handle deeply nested fields like mandate_general.contact_info.email
const [parent, child, grandchild] = fieldParts;
if (parent === 'mandate_general' && child === 'contact_info' && grandchild in newData.mandate_general.contact_info) {
(newData.mandate_general.contact_info as any)[grandchild] = value;
}
} else if (field === 'setup_contacts') {
newData.setup_contacts = value === 'true';
}
setFormData(newData);
setSaveMessage(null);
};
const handleFocus = (field: string) => {
setFocusedFields(prev => new Set(prev).add(field));
};
const handleBlur = (field: string) => {
setFocusedFields(prev => {
const newSet = new Set(prev);
newSet.delete(field);
return newSet;
});
};
const handleSave = async () => {
if (!formData) return;
setIsSaving(true);
try {
// Save to localStorage
localStorage.setItem('speechSignUpData', JSON.stringify(formData));
localStorage.setItem('speechSignUpTimestamp', Date.now().toString());
// Dispatch event to notify other components
window.dispatchEvent(new CustomEvent('speechSignUpChanged'));
setSaveMessage({ type: 'success', text: t('speech.settings.save_success') });
// Notify parent component if callback provided
if (onDataUpdate) {
onDataUpdate(formData);
}
// Clear message after 3 seconds
setTimeout(() => setSaveMessage(null), 3000);
} catch (error) {
console.error('Error saving speech settings:', error);
setSaveMessage({ type: 'error', text: t('speech.settings.save_error') });
} finally {
setIsSaving(false);
}
};
const handleReset = () => {
if (window.confirm(t('speech.settings.reset_confirm'))) {
localStorage.removeItem('speechSignUpData');
localStorage.removeItem('speechSignUpTimestamp');
window.dispatchEvent(new CustomEvent('speechSignUpChanged'));
setFormData(null);
setSaveMessage({ type: 'success', text: t('speech.settings.reset_success') });
setTimeout(() => setSaveMessage(null), 3000);
}
};
if (isLoading) {
return (
<div className={styles.speechSettingsForm}>
<div className={styles.loading}>
{t('common.loading')}
</div>
</div>
);
}
if (!formData) {
return (
<div className={styles.speechSettingsForm}>
<div className={styles.noData}>
<p>{t('speech.settings.no_data')}</p>
<button
className={styles.signUpButton}
onClick={() => navigate('/speech')}
>
{t('speech.settings.sign_up_now')}
</button>
</div>
</div>
);
}
return (
<div className={styles.speechSettingsForm}>
<span className={styles.settingLabel}>{t('speech.settings.title')}</span>
<span className={styles.settingDescription}>{t('speech.settings.description')}</span>
{saveMessage && (
<div className={`${styles.updateMessage} ${saveMessage.type === 'success' ? styles.successMessage : styles.errorMessage}`}>
{saveMessage.text}
</div>
)}
{/* Company Information Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<IoIosBusiness className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>{t('speech.settings.company_info')}</h3>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<label className={styles.fieldLabel}>
{t('speech.signup.company_name')} *
</label>
<input
type="text"
className={styles.formInput}
value={formData.mandate_general.company_name}
onChange={(e) => handleInputChange('mandate_general.company_name', e.target.value)}
onFocus={() => handleFocus('company_name')}
onBlur={() => handleBlur('company_name')}
required
/>
</div>
<div className={styles.formField}>
<label className={styles.fieldLabel}>
{t('speech.signup.industry')} *
</label>
<input
type="text"
className={styles.formInput}
value={formData.mandate_general.industry}
onChange={(e) => handleInputChange('mandate_general.industry', e.target.value)}
onFocus={() => handleFocus('industry')}
onBlur={() => handleBlur('industry')}
required
/>
</div>
</div>
</div>
{/* Contact Information Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<IoIosContact className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>{t('speech.settings.contact_info')}</h3>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<label className={styles.fieldLabel}>
{t('speech.signup.email')} *
</label>
<input
type="email"
className={styles.formInput}
value={formData.mandate_general.contact_info.email}
onChange={(e) => handleInputChange('mandate_general.contact_info.email', e.target.value)}
onFocus={() => handleFocus('email')}
onBlur={() => handleBlur('email')}
required
/>
</div>
<div className={styles.formField}>
<label className={styles.fieldLabel}>
{t('speech.signup.phone')} *
</label>
<input
type="tel"
className={styles.formInput}
value={formData.mandate_general.contact_info.phone}
onChange={(e) => handleInputChange('mandate_general.contact_info.phone', e.target.value)}
onFocus={() => handleFocus('phone')}
onBlur={() => handleBlur('phone')}
required
/>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<label className={styles.fieldLabel}>
{t('speech.signup.street')} *
</label>
<input
type="text"
className={styles.formInput}
value={formData.mandate_general.contact_info.street}
onChange={(e) => handleInputChange('mandate_general.contact_info.street', e.target.value)}
onFocus={() => handleFocus('street')}
onBlur={() => handleBlur('street')}
required
/>
</div>
<div className={styles.formField}>
<label className={styles.fieldLabel}>
{t('speech.signup.postal_code')} *
</label>
<input
type="text"
className={styles.formInput}
value={formData.mandate_general.contact_info.postal_code}
onChange={(e) => handleInputChange('mandate_general.contact_info.postal_code', e.target.value)}
onFocus={() => handleFocus('postal_code')}
onBlur={() => handleBlur('postal_code')}
required
/>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<label className={styles.fieldLabel}>
{t('speech.signup.city')} *
</label>
<input
type="text"
className={styles.formInput}
value={formData.mandate_general.contact_info.city}
onChange={(e) => handleInputChange('mandate_general.contact_info.city', e.target.value)}
onFocus={() => handleFocus('city')}
onBlur={() => handleBlur('city')}
required
/>
</div>
<div className={styles.formField}>
<label className={styles.fieldLabel}>
{t('speech.signup.country')} *
</label>
<input
type="text"
className={styles.formInput}
value={formData.mandate_general.contact_info.country}
onChange={(e) => handleInputChange('mandate_general.contact_info.country', e.target.value)}
onFocus={() => handleFocus('country')}
onBlur={() => handleBlur('country')}
required
/>
</div>
</div>
</div>
{/* Business Hours Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<IoIosTime className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>{t('speech.settings.business_hours')}</h3>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<label className={styles.fieldLabel}>
{t('speech.signup.business_hours')} *
</label>
<input
type="text"
className={styles.formInput}
value={formData.mandate_general.business_hours}
onChange={(e) => handleInputChange('mandate_general.business_hours', e.target.value)}
onFocus={() => handleFocus('business_hours')}
onBlur={() => handleBlur('business_hours')}
required
/>
</div>
<div className={styles.formField}>
<label className={styles.fieldLabel}>
{t('speech.signup.timezone')} *
</label>
<select
className={styles.formSelect}
value={formData.mandate_general.timezone}
onChange={(e) => handleInputChange('mandate_general.timezone', e.target.value)}
onFocus={() => handleFocus('timezone')}
onBlur={() => handleBlur('timezone')}
required
>
<option value="">{t('speech.signup.select_timezone')}</option>
<option value="UTC-12">UTC-12 (Baker Island)</option>
<option value="UTC-11">UTC-11 (American Samoa)</option>
<option value="UTC-10">UTC-10 (Hawaii)</option>
<option value="UTC-9">UTC-9 (Alaska)</option>
<option value="UTC-8">UTC-8 (Pacific Time)</option>
<option value="UTC-7">UTC-7 (Mountain Time)</option>
<option value="UTC-6">UTC-6 (Central Time)</option>
<option value="UTC-5">UTC-5 (Eastern Time)</option>
<option value="UTC-4">UTC-4 (Atlantic Time)</option>
<option value="UTC-3">UTC-3 (Brazil)</option>
<option value="UTC-2">UTC-2 (Mid-Atlantic)</option>
<option value="UTC-1">UTC-1 (Azores)</option>
<option value="UTC+0">UTC+0 (Greenwich Mean Time)</option>
<option value="UTC+1">UTC+1 (Central European Time)</option>
<option value="UTC+2">UTC+2 (Eastern European Time)</option>
<option value="UTC+3">UTC+3 (Moscow Time)</option>
<option value="UTC+4">UTC+4 (Gulf Standard Time)</option>
<option value="UTC+5">UTC+5 (Pakistan Standard Time)</option>
<option value="UTC+6">UTC+6 (Bangladesh Standard Time)</option>
<option value="UTC+7">UTC+7 (Indochina Time)</option>
<option value="UTC+8">UTC+8 (China Standard Time)</option>
<option value="UTC+9">UTC+9 (Japan Standard Time)</option>
<option value="UTC+10">UTC+10 (Australian Eastern Time)</option>
<option value="UTC+11">UTC+11 (Solomon Islands)</option>
<option value="UTC+12">UTC+12 (New Zealand)</option>
</select>
</div>
</div>
</div>
{/* Actions */}
<div className={styles.formActions}>
<button
className={styles.resetButton}
onClick={handleReset}
disabled={isSaving}
>
<IoIosRefresh className={styles.resetIcon} />
{t('speech.settings.reset')}
</button>
<button
className={styles.saveButton}
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? t('speech.settings.saving') : t('speech.settings.save')}
</button>
</div>
</div>
);
}
export default SettingsSpeech;

View file

@ -3,6 +3,7 @@ import { useState } from 'react';
import { useMsal } from '@azure/msal-react';
import api from '../api';
import { useApiRequest } from './useApi';
import { getApiBaseUrl } from '../../config/config';
// Regular authentication
interface LoginResponse {
@ -114,7 +115,7 @@ export function useMsalAuth() {
try {
return new Promise((resolve, reject) => {
const backendUrl = import.meta.env.VITE_API_BASE_URL;
const backendUrl = getApiBaseUrl();
const loginUrl = `${backendUrl}/api/msft/login?state=login`;
console.log('🔐 Starting MSAL authentication...');
@ -227,7 +228,7 @@ export function useMsalAuth() {
setMsalError('Authentication timeout - please check your internet connection and try again');
reject(new Error('Authentication timeout'));
}
}, 10000);
}, 60000);
// Override popup.close to mark as manually closed
const originalClose = popup.close;

View file

@ -1,5 +1,8 @@
import { useState } from 'react';
import { useApiRequest } from './useApi';
import { getApiBaseUrl } from '../../config/config';
// Connection interfaces - exactly matching backend UserConnection model
export interface Connection {
@ -194,7 +197,7 @@ export function useOAuthConnect() {
// Convert relative URL to absolute URL if needed
let authUrl = response.authUrl;
if (authUrl.startsWith('/')) {
authUrl = `${import.meta.env.VITE_API_BASE_URL}${authUrl}`;
authUrl = `${getApiBaseUrl()}${authUrl}`;
}
// Open popup using the same pattern as useAuthentication.ts
@ -228,7 +231,7 @@ export function useOAuthConnect() {
// Listen for messages from the popup (similar to useMsalAuth)
const messageListener = (event: MessageEvent) => {
// Verify origin for security
const apiUrl = new URL(import.meta.env.VITE_API_BASE_URL);
const apiUrl = new URL(getApiBaseUrl());
if (event.origin !== apiUrl.origin) {
return;
}

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState, useEffect } from 'react';
import { getSidebarItems } from '../components/PageManager/pageConfigs';
import { SidebarItem } from '../components/PageManager/pageConfigInterface';
import { useLanguage } from '../contexts/LanguageContext';
@ -6,9 +6,40 @@ import { useLanguage } from '../contexts/LanguageContext';
// Hook to get sidebar items from page configurations
export const useSidebarFromPageConfigs = (): SidebarItem[] => {
const { t } = useLanguage();
const [refreshTrigger, setRefreshTrigger] = useState(0);
// Listen for localStorage changes to refresh sidebar when sign-up status changes
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'speechSignUpData' || e.key === 'speechSignUpTimestamp') {
setRefreshTrigger(prev => prev + 1);
}
};
window.addEventListener('storage', handleStorageChange);
// Also listen for changes in the same tab
const handleCustomStorageChange = () => {
setRefreshTrigger(prev => prev + 1);
};
// Custom event for same-tab changes
window.addEventListener('speechSignUpChanged', handleCustomStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('speechSignUpChanged', handleCustomStorageChange);
};
}, []);
return useMemo(() => {
console.log('🔄 Sidebar refreshing, trigger:', refreshTrigger);
const sidebarItems = getSidebarItems();
console.log('📋 Sidebar items:', sidebarItems.map(item => ({
name: item.name,
hasSubmenu: !!item.submenu,
submenuCount: item.submenu?.length || 0
})));
// Map the items with translations
return sidebarItems.map(item => ({
@ -17,7 +48,7 @@ export const useSidebarFromPageConfigs = (): SidebarItem[] => {
// For now, we'll use the names from pageConfigs directly
name: getTranslatedName(item.name, t)
}));
}, [t]);
}, [t, refreshTrigger]);
};
// Helper function to get translated names
@ -30,7 +61,9 @@ const getTranslatedName = (name: string, t: (key: string) => string): string =>
'Connections': t('nav.connections'),
'Team Bereich': t('nav.team'),
'Einstellungen': t('nav.settings'),
'Test Sharepoint': t('nav.testSharepoint')
'Test Sharepoint': t('nav.testSharepoint'),
'Speech': t('nav.speech'),
'Transkriptverwaltung': t('nav.transcript_management')
};
return translationMap[name] || name;

View file

@ -7,6 +7,8 @@ export default {
'nav.workflows': 'Workflows',
'nav.settings': 'Einstellungen',
'nav.testSharepoint': 'SharePoint Test',
'nav.speech': 'Sprache',
'nav.transcript_management': 'Transkriptverwaltung',
// Settings page
'settings.title': 'Einstellungen',
@ -414,6 +416,7 @@ export default {
// FormGenerator
'formgen.search.placeholder': 'Suchen...',
'formgen.refresh.tooltip': 'Daten aktualisieren',
'formgen.filter.yes': 'Ja',
'formgen.filter.no': 'Nein',
'formgen.filter.clear': 'Filter löschen',
@ -498,4 +501,141 @@ export default {
'sharepoint.sites.retryConnection': 'Versuchen Sie, Ihr Microsoft-Konto auf der Verbindungsseite erneut zu verbinden.',
'sharepoint.form.siteUrl': 'SharePoint Site URL',
'sharepoint.form.folderPaths': 'Ordnerpfade',
// Speech
'speech.title': 'Sprach Integration',
'speech.subtitle': 'Unterstützt von',
'speech.signup.title': 'Sprach Integration',
'speech.signup.subtitle': 'Unterstützt von',
'speech.info.va': 'Virtual Assistant (VA)',
'speech.info.va_description': 'Geben Sie Kunden einen schnellen und effizienten Selbstservice für Sprach- und Textanfragen, der 24/7 verfügbar ist.',
'speech.info.sa': 'Speech Analytics (SA)',
'speech.info.sa_description': 'Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.',
'speech.info.vb': 'Voice Biometrics (VB)',
'speech.info.vb_description': 'Identifizieren und authentifizieren Sie Anrufer in Sekunden mit kontinuierlicher Verifizierung und Sicherheit.',
'speech.info.ka': 'Knowledge Agent (KA)',
'speech.info.ka_description': 'Vereinheitlichen und liefern Sie Informationen an Ihre Kunden und Mitarbeiter, wann und wo sie sie benötigen.',
'speech.info.cp': 'Chat Platform (CP)',
'speech.info.cp_description': 'Bieten Sie Unterstützung im Live-Chat und setzen Sie intelligente Chatbots in allen Kanälen ein.',
'speech.info.aa': 'Agent Assist (AA)',
'speech.info.aa_description': 'Stellen Sie alles, was Ihre Agenten benötigen, in ihren Händen bereit, mit einem einheitlichen Agent-Desktop.',
'speech.info.about': 'Revolutionäre Telefonie-Integration mit Spitch.ai',
'speech.info.about_intro': 'Erleben Sie die Zukunft der Mandantenkommunikation durch unsere strategische Partnerschaft mit Spitch.ai. Diese bahnbrechende Integration verwandelt Ihre PowerOn-Plattform in ein intelligentes Telefonie-System, das externe Mandanten nahtlos mit Unternehmen verbindet.',
'speech.info.workflow_title': 'Nahtloser Mandanten-Workflow:',
'speech.info.workflow_description': 'Von der Registrierung bis zur technischen Einrichtung - Ihr Mandant registriert sich bei PowerOn für Telefonie-Services, lädt Dokumente hoch und erhält automatisch eine technische SIP-Nummer von Spitch. Die Call-Weiterleitung kann jederzeit aktiviert oder deaktiviert werden, was maximale Flexibilität und BCM-Sicherheit gewährleistet.',
'speech.info.ai_title': 'KI-gestützte Dokumentengenerierung:',
'speech.info.ai_description': 'Unsere bereits aktive Dokumenten-Extraktions-Engine generiert automatisch personalisierte Dokumente für Spitch, basierend auf Mandantenspezifischen Daten. Die KI nutzt FAQ-Datenbanken, Mitarbeiterinformationen und Service-Details, um jeden Anruf kontextuell und hochpersonalisiert zu gestalten.',
'speech.info.sync_title': 'Echtzeit-Datensynchronisation:',
'speech.info.sync_description': 'Spitch prüft vor jedem Anruf die Mandantenberechtigung bei PowerOn, während alle Datenänderungen zentral von PowerOn initiiert werden. Call-Transkripte werden in Echtzeit in Ihrer PowerOn-Datenbank gespeichert, mit vollständiger Mandantenisolation und Sicherheit. Bei Ausfällen werden Anrufe automatisch blockiert, um die Integrität zu gewährleisten.',
'speech.info.cost_title': 'Kosteneinsparungen & Effizienz:',
'speech.info.cost_description': 'Mandanten können jederzeit auf die technische SIP-Nummer umstellen und dabei erhebliche Telefoniekosten sparen. Die Integration funktioniert wie ein weiterer Connector (Outlook, SharePoint) und wird nahtlos in Ihren bestehenden Workflow integriert.',
'speech.info.about_link': 'Mehr erfahren',
'speech.signup.button': 'Verbinden',
'speech.signup.title': 'Mandat für Sprach Integration erstellen',
'speech.signup.subtitle': 'Erstellen Sie Ihr Mandat für die Spitch.ai Integration',
'speech.signup.back': 'Zurück zur Sprach Integration',
'speech.signup.submit': 'Mandat erstellen',
'speech.signup.cancel': 'Abbrechen',
'speech.signup.company_info': 'Unternehmensinformationen',
'speech.signup.company_name': 'Firmenname',
'speech.signup.company_name_placeholder': 'Geben Sie Ihren Firmennamen ein',
'speech.signup.industry': 'Branche',
'speech.signup.industry_placeholder': 'z.B. Finanzdienstleistungen, Technologie, etc.',
'speech.signup.business_hours': 'Geschäftszeiten',
'speech.signup.timezone': 'Zeitzone',
'speech.signup.contact_info': 'Kontaktinformationen',
'speech.signup.email': 'E-Mail-Adresse',
'speech.signup.email_placeholder': 'kontakt@firma.com',
'speech.signup.phone': 'Telefonnummer',
'speech.signup.phone_placeholder': '+41 123 456 789',
'speech.signup.street': 'Straße',
'speech.signup.postal_code': 'Postleitzahl',
'speech.signup.city': 'Stadt',
'speech.signup.country': 'Land',
'speech.signup.contacts_setup': 'Kontakte einrichten',
'speech.signup.contacts_description': 'Möchten Sie jetzt Kontakte für Ihr Mandat einrichten? Sie können dies auch später in den Einstellungen tun.',
'speech.signup.setup_contacts': 'Kontakte einrichten',
'speech.signup.skip_for_now': 'Jetzt überspringen',
'speech.signup.company_required': 'Firmenname ist erforderlich',
'speech.signup.industry_required': 'Branche ist erforderlich',
'speech.signup.email_required': 'E-Mail-Adresse ist erforderlich',
'speech.signup.email_invalid': 'Bitte geben Sie eine gültige E-Mail-Adresse ein',
'speech.signup.phone_required': 'Telefonnummer ist erforderlich',
'speech.signup.street_required': 'Straße ist erforderlich',
'speech.signup.postal_code_required': 'Postleitzahl ist erforderlich',
'speech.signup.city_required': 'Stadt ist erforderlich',
'speech.signup.country_required': 'Land ist erforderlich',
'speech.status.submitted': '✓ Mandat eingereicht',
'speech.status.reset': 'Neu starten',
'speech.confirmation.title': 'Mandat erfolgreich eingereicht!',
'speech.confirmation.message': 'Vielen Dank für Ihr Interesse an unserer Sprach Integration powered by Spitch.ai. Wir haben Ihr Mandat erhalten und werden es in Kürze überprüfen.',
'speech.confirmation.submitted_data': 'Eingereichte Daten:',
'speech.confirmation.company': 'Firma',
'speech.confirmation.industry': 'Branche',
'speech.confirmation.email': 'E-Mail',
'speech.confirmation.phone': 'Telefon',
'speech.confirmation.address': 'Adresse',
'speech.confirmation.timezone': 'Zeitzone',
'speech.confirmation.back': 'Zurück zur Sprach Integration',
'speech.confirmation.reset': 'Neu starten',
'speech.confirmation.next_steps': 'Was passiert als nächstes?',
'speech.confirmation.email_confirmation': 'E-Mail-Bestätigung',
'speech.confirmation.email_confirmation_desc': 'Sie erhalten in den nächsten Minuten eine Bestätigungs-E-Mail.',
'speech.confirmation.review_process': 'Überprüfungsprozess',
'speech.confirmation.review_process_desc': 'Unser Team wird Ihr Mandat innerhalb von 1-2 Werktagen überprüfen.',
'speech.confirmation.setup_call': 'Einrichtungsanruf',
'speech.confirmation.setup_call_desc': 'Bei Genehmigung planen wir einen Einrichtungsanruf zur Konfiguration Ihrer Integration.',
'speech.confirmation.questions': 'Fragen?',
'speech.confirmation.questions_desc': 'Falls Sie Fragen zu Ihrem Mandat oder dem Integrationsprozess haben, zögern Sie nicht, unser Support-Team zu kontaktieren.',
'speech.confirmation.transcript_management': 'Transkriptverwaltung',
'speech.confirmation.speech_settings': 'Sprach-Einstellungen',
'speech.transcripts.title': 'Transkriptverwaltung',
'speech.transcripts.new_transcript': 'Neues Transkript',
'speech.transcripts.recent_transcripts': 'Aktuelle Transkripte',
'speech.transcripts.no_transcripts': 'Keine Transkripte vorhanden',
'speech.transcripts.date': 'Datum',
'speech.transcripts.duration': 'Dauer',
'speech.transcripts.status': 'Status',
'speech.transcripts.transcript': 'Transkript',
'speech.transcripts.processing': 'Transkript wird verarbeitet...',
'speech.transcripts.status.completed': 'Abgeschlossen',
'speech.transcripts.status.processing': 'Verarbeitung',
'speech.transcripts.status.failed': 'Fehlgeschlagen',
'speech.transcripts.access_denied_title': 'Zugriff verweigert',
'speech.transcripts.access_denied_message': 'Sie müssen sich zuerst für die Sprach-Integration anmelden, um auf die Transkriptverwaltung zuzugreifen.',
'speech.transcripts.sign_up_now': 'Jetzt anmelden',
'speech.transcripts.subject': 'Betreff',
'speech.transcripts.start_time': 'Startzeit',
'speech.transcripts.end_time': 'Endzeit',
'speech.transcripts.caller': 'Anrufer',
'speech.transcripts.recipient': 'Empfänger',
'speech.transcripts.tags': 'Tags',
'speech.transcripts.created': 'Erstellt',
'speech.transcripts.view': 'Anzeigen',
'speech.transcripts.download': 'Herunterladen',
'speech.settings.title': 'Sprach-Integration Einstellungen',
'speech.settings.description': 'Verwalten Sie Ihre Sprach-Integrations-Konfiguration und Einstellungen.',
'speech.settings.company_info': 'Unternehmensinformationen',
'speech.settings.contact_info': 'Kontaktinformationen',
'speech.settings.business_hours': 'Geschäftszeiten & Zeitzone',
'speech.settings.save': 'Änderungen speichern',
'speech.settings.saving': 'Speichern...',
'speech.settings.save_success': 'Einstellungen erfolgreich gespeichert!',
'speech.settings.save_error': 'Fehler beim Speichern der Einstellungen. Bitte versuchen Sie es erneut.',
'speech.settings.reset': 'Auf Standard zurücksetzen',
'speech.settings.reset_confirm': 'Sind Sie sicher, dass Sie alle Sprach-Integrations-Einstellungen zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.',
'speech.settings.reset_success': 'Einstellungen wurden erfolgreich zurückgesetzt.',
'speech.settings.no_data': 'Keine Sprach-Integrations-Daten gefunden. Bitte melden Sie sich zuerst an, um auf die Einstellungen zuzugreifen.',
'speech.settings.sign_up_now': 'Jetzt anmelden',
};

View file

@ -7,6 +7,8 @@ export default {
'nav.connections': 'Connections',
'nav.settings': 'Settings',
'nav.testSharepoint': 'Test SharePoint',
'nav.speech': 'Speech',
'nav.transcript_management': 'Transcript Management',
// Settings page
'settings.title': 'Settings',
@ -417,6 +419,7 @@ export default {
// FormGenerator
'formgen.search.placeholder': 'Search...',
'formgen.refresh.tooltip': 'Refresh data',
'formgen.filter.yes': 'Yes',
'formgen.filter.no': 'No',
'formgen.filter.clear': 'Clear filter',
@ -498,4 +501,141 @@ export default {
'sharepoint.sites.retryConnection': 'Try reconnecting your Microsoft account in the Connections page.',
'sharepoint.form.siteUrl': 'SharePoint Site URL',
'sharepoint.form.folderPaths': 'Folder Paths',
// Speech
'speech.title': 'Speech Integration',
'speech.subtitle': 'Powered by',
'speech.signup.title': 'Speech Integration',
'speech.signup.subtitle': 'Powered by',
'speech.info.va': 'Virtual Assistant (VA)',
'speech.info.va_description': 'Give customers a fast and efficient self-service for voice and text queries that\'s available 24/7.',
'speech.info.sa': 'Speech Analytics (SA)',
'speech.info.sa_description': 'Automatically monitor 100% of conversations to get valuable insights for your business.',
'speech.info.vb': 'Voice Biometrics (VB)',
'speech.info.vb_description': 'Identify and authenticate callers in seconds with continuous verification and security.',
'speech.info.ka': 'Knowledge Agent (KA)',
'speech.info.ka_description': 'Unify and deliver info to your customers and staff wherever and whenever they need it.',
'speech.info.cp': 'Chat Platform (CP)',
'speech.info.cp_description': 'Deliver assistance in live chat and deploy intelligent chatbots in all channels.',
'speech.info.aa': 'Agent Assist (AA)',
'speech.info.aa_description': 'Put everything your agents need at their fingertips, with a unified agent desktop.',
'speech.info.about': 'Revolutionary Telephony Integration with Spitch.ai',
'speech.info.about_intro': 'Experience the future of client communication through our strategic partnership with Spitch.ai. This groundbreaking integration transforms your PowerOn platform into an intelligent telephony system that seamlessly connects external clients with companies.',
'speech.info.workflow_title': 'Seamless Client Workflow:',
'speech.info.workflow_description': 'From registration to technical setup - your client registers with PowerOn for telephony services, uploads documents, and automatically receives a technical SIP number from Spitch. Call forwarding can be activated or deactivated at any time, ensuring maximum flexibility and BCM safety.',
'speech.info.ai_title': 'AI-Powered Document Generation:',
'speech.info.ai_description': 'Our already active document extraction engine automatically generates personalized documents for Spitch based on client-specific data. The AI uses FAQ databases, employee information, and service details to make every call contextual and highly personalized.',
'speech.info.sync_title': 'Real-time Data Synchronization:',
'speech.info.sync_description': 'Spitch checks client authorization with PowerOn before each call, while all data changes are centrally initiated by PowerOn. Call transcripts are stored in real-time in your PowerOn database with complete client isolation and security. In case of failures, calls are automatically blocked to ensure integrity.',
'speech.info.cost_title': 'Cost Savings & Efficiency:',
'speech.info.cost_description': 'Clients can switch to the technical SIP number at any time and save significant telephony costs. The integration works like another connector (Outlook, SharePoint) and is seamlessly integrated into your existing workflow.',
'speech.info.about_link': 'Learn more',
'speech.signup.button': 'Connect',
'speech.signup.title': 'Create Mandate for Speech Integration',
'speech.signup.subtitle': 'Create your mandate for Spitch.ai integration',
'speech.signup.back': 'Back to Speech Integration',
'speech.signup.submit': 'Create Mandate',
'speech.signup.cancel': 'Cancel',
'speech.signup.company_info': 'Company Information',
'speech.signup.company_name': 'Company Name',
'speech.signup.company_name_placeholder': 'Enter your company name',
'speech.signup.industry': 'Industry',
'speech.signup.industry_placeholder': 'e.g. Financial Services, Technology, etc.',
'speech.signup.business_hours': 'Business Hours',
'speech.signup.timezone': 'Timezone',
'speech.signup.contact_info': 'Contact Information',
'speech.signup.email': 'Email Address',
'speech.signup.email_placeholder': 'contact@company.com',
'speech.signup.phone': 'Phone Number',
'speech.signup.phone_placeholder': '+41 123 456 789',
'speech.signup.street': 'Street',
'speech.signup.postal_code': 'Postal Code',
'speech.signup.city': 'City',
'speech.signup.country': 'Country',
'speech.signup.contacts_setup': 'Setup Contacts',
'speech.signup.contacts_description': 'Would you like to setup contacts for your mandate now? You can also do this later in settings.',
'speech.signup.setup_contacts': 'Setup Contacts',
'speech.signup.skip_for_now': 'Skip for Now',
'speech.signup.company_required': 'Company name is required',
'speech.signup.industry_required': 'Industry is required',
'speech.signup.email_required': 'Email address is required',
'speech.signup.email_invalid': 'Please enter a valid email address',
'speech.signup.phone_required': 'Phone number is required',
'speech.signup.street_required': 'Street is required',
'speech.signup.postal_code_required': 'Postal code is required',
'speech.signup.city_required': 'City is required',
'speech.signup.country_required': 'Country is required',
'speech.status.submitted': '✓ Mandate Submitted',
'speech.status.reset': 'Start Over',
'speech.confirmation.title': 'Mandate Submitted Successfully!',
'speech.confirmation.message': 'Thank you for your interest in our Speech Integration powered by Spitch.ai. We have received your mandate and will review it shortly.',
'speech.confirmation.submitted_data': 'Submitted Data:',
'speech.confirmation.company': 'Company',
'speech.confirmation.industry': 'Industry',
'speech.confirmation.email': 'Email',
'speech.confirmation.phone': 'Phone',
'speech.confirmation.address': 'Address',
'speech.confirmation.timezone': 'Timezone',
'speech.confirmation.back': 'Back to Speech Integration',
'speech.confirmation.reset': 'Start Over',
'speech.confirmation.next_steps': 'What happens next?',
'speech.confirmation.email_confirmation': 'Email Confirmation',
'speech.confirmation.email_confirmation_desc': 'You will receive a confirmation email within the next few minutes.',
'speech.confirmation.review_process': 'Review Process',
'speech.confirmation.review_process_desc': 'Our team will review your mandate within 1-2 business days.',
'speech.confirmation.setup_call': 'Setup Call',
'speech.confirmation.setup_call_desc': 'If approved, we\'ll schedule a setup call to configure your integration.',
'speech.confirmation.questions': 'Questions?',
'speech.confirmation.questions_desc': 'If you have any questions about your mandate or the integration process, please don\'t hesitate to contact our support team.',
'speech.confirmation.transcript_management': 'Transcript Management',
'speech.confirmation.speech_settings': 'Speech Settings',
'speech.transcripts.title': 'Transcript Management',
'speech.transcripts.new_transcript': 'New Transcript',
'speech.transcripts.recent_transcripts': 'Recent Transcripts',
'speech.transcripts.no_transcripts': 'No transcripts available',
'speech.transcripts.date': 'Date',
'speech.transcripts.duration': 'Duration',
'speech.transcripts.status': 'Status',
'speech.transcripts.transcript': 'Transcript',
'speech.transcripts.processing': 'Processing transcript...',
'speech.transcripts.status.completed': 'Completed',
'speech.transcripts.status.processing': 'Processing',
'speech.transcripts.status.failed': 'Failed',
'speech.transcripts.access_denied_title': 'Access Denied',
'speech.transcripts.access_denied_message': 'You must first sign up for speech integration to access transcript management.',
'speech.transcripts.sign_up_now': 'Sign Up Now',
'speech.transcripts.subject': 'Subject',
'speech.transcripts.start_time': 'Start Time',
'speech.transcripts.end_time': 'End Time',
'speech.transcripts.caller': 'Caller',
'speech.transcripts.recipient': 'Recipient',
'speech.transcripts.tags': 'Tags',
'speech.transcripts.created': 'Created',
'speech.transcripts.view': 'View',
'speech.transcripts.download': 'Download',
'speech.settings.title': 'Speech Integration Settings',
'speech.settings.description': 'Manage your speech integration configuration and preferences.',
'speech.settings.company_info': 'Company Information',
'speech.settings.contact_info': 'Contact Information',
'speech.settings.business_hours': 'Business Hours & Timezone',
'speech.settings.save': 'Save Changes',
'speech.settings.saving': 'Saving...',
'speech.settings.save_success': 'Settings saved successfully!',
'speech.settings.save_error': 'Failed to save settings. Please try again.',
'speech.settings.reset': 'Reset to Default',
'speech.settings.reset_confirm': 'Are you sure you want to reset all speech integration settings? This action cannot be undone.',
'speech.settings.reset_success': 'Settings have been reset successfully.',
'speech.settings.no_data': 'No speech integration data found. Please sign up first to access settings.',
'speech.settings.sign_up_now': 'Sign Up Now',
};

View file

@ -7,6 +7,8 @@ export default {
'nav.connections': 'Connections',
'nav.settings': 'Paramètres',
'nav.testSharepoint': 'Test SharePoint',
'nav.speech': 'Parole',
'nav.transcript_management': 'Gestion des Transcriptions',
// Settings page
'settings.title': 'Paramètres',
@ -417,6 +419,7 @@ export default {
// FormGenerator
'formgen.search.placeholder': 'Rechercher...',
'formgen.refresh.tooltip': 'Actualiser les données',
'formgen.filter.yes': 'Oui',
'formgen.filter.no': 'Non',
'formgen.filter.clear': 'Effacer le filtre',
@ -498,4 +501,141 @@ export default {
'sharepoint.sites.retryConnection': 'Essayez de reconnecter votre compte Microsoft dans la page Connexions.',
'sharepoint.form.siteUrl': 'URL du site SharePoint',
'sharepoint.form.folderPaths': 'Chemins des dossiers',
// Speech
'speech.title': 'Intégration Vocale',
'speech.subtitle': 'Alimenté par ',
'speech.signup.title': 'Intégration Vocale',
'speech.signup.subtitle': 'Alimenté par',
'speech.info.va': 'Assistant Virtuel (VA)',
'speech.info.va_description': 'Offrez aux clients un libre-service rapide et efficace pour les requêtes vocales et textuelles disponible 24h/24.',
'speech.info.sa': 'Analyse Vocale (SA)',
'speech.info.sa_description': 'Surveillez automatiquement 100% des conversations pour obtenir des insights précieux pour votre entreprise.',
'speech.info.vb': 'Biométrie Vocale (VB)',
'speech.info.vb_description': 'Identifiez et authentifiez les appelants en quelques secondes avec une vérification et sécurité continues.',
'speech.info.ka': 'Agent de Connaissance (KA)',
'speech.info.ka_description': 'Unifiez et livrez des informations à vos clients et employés où et quand ils en ont besoin.',
'speech.info.cp': 'Plateforme de Chat (CP)',
'speech.info.cp_description': 'Offrez une assistance en chat en direct et déployez des chatbots intelligents sur tous les canaux.',
'speech.info.aa': 'Assistance Agent (AA)',
'speech.info.aa_description': 'Mettez tout ce dont vos agents ont besoin à portée de main, avec un bureau d\'agent unifié.',
'speech.info.about': 'Intégration Téléphonique Révolutionnaire avec Spitch.ai',
'speech.info.about_intro': 'Découvrez l\'avenir de la communication client grâce à notre partenariat stratégique avec Spitch.ai. Cette intégration révolutionnaire transforme votre plateforme PowerOn en un système téléphonique intelligent qui connecte de manière transparente les clients externes avec les entreprises.',
'speech.info.workflow_title': 'Workflow Client Transparent:',
'speech.info.workflow_description': 'De l\'inscription à la configuration technique - votre client s\'inscrit auprès de PowerOn pour les services téléphoniques, télécharge des documents et reçoit automatiquement un numéro SIP technique de Spitch. Le transfert d\'appel peut être activé ou désactivé à tout moment, garantissant une flexibilité maximale et la sécurité BCM.',
'speech.info.ai_title': 'Génération de Documents alimentée par l\'IA:',
'speech.info.ai_description': 'Notre moteur d\'extraction de documents déjà actif génère automatiquement des documents personnalisés pour Spitch basés sur les données spécifiques au client. L\'IA utilise les bases de données FAQ, les informations employés et les détails de service pour rendre chaque appel contextuel et hautement personnalisé.',
'speech.info.sync_title': 'Synchronisation de Données en Temps Réel:',
'speech.info.sync_description': 'Spitch vérifie l\'autorisation client avec PowerOn avant chaque appel, tandis que tous les changements de données sont initiés centralement par PowerOn. Les transcriptions d\'appels sont stockées en temps réel dans votre base de données PowerOn avec une isolation complète du client et la sécurité. En cas de panne, les appels sont automatiquement bloqués pour assurer l\'intégrité.',
'speech.info.cost_title': 'Économies de Coûts & Efficacité:',
'speech.info.cost_description': 'Les clients peuvent basculer sur le numéro SIP technique à tout moment et économiser des coûts téléphoniques significatifs. L\'intégration fonctionne comme un autre connecteur (Outlook, SharePoint) et est intégrée de manière transparente dans votre workflow existant.',
'speech.info.about_link': 'En savoir plus',
'speech.signup.button': 'Connecter',
'speech.signup.title': 'Créer un Mandat pour l\'Intégration Vocale',
'speech.signup.subtitle': 'Créez votre mandat pour l\'intégration Spitch.ai',
'speech.signup.back': 'Retour à l\'Intégration Vocale',
'speech.signup.submit': 'Créer le Mandat',
'speech.signup.cancel': 'Annuler',
'speech.signup.company_info': 'Informations de l\'Entreprise',
'speech.signup.company_name': 'Nom de l\'Entreprise',
'speech.signup.company_name_placeholder': 'Entrez le nom de votre entreprise',
'speech.signup.industry': 'Secteur d\'Activité',
'speech.signup.industry_placeholder': 'ex. Services Financiers, Technologie, etc.',
'speech.signup.business_hours': 'Heures d\'Ouverture',
'speech.signup.timezone': 'Fuseau Horaire',
'speech.signup.contact_info': 'Informations de Contact',
'speech.signup.email': 'Adresse Email',
'speech.signup.email_placeholder': 'contact@entreprise.com',
'speech.signup.phone': 'Numéro de Téléphone',
'speech.signup.phone_placeholder': '+41 123 456 789',
'speech.signup.street': 'Rue',
'speech.signup.postal_code': 'Code Postal',
'speech.signup.city': 'Ville',
'speech.signup.country': 'Pays',
'speech.signup.contacts_setup': 'Configurer les Contacts',
'speech.signup.contacts_description': 'Souhaitez-vous configurer les contacts pour votre mandat maintenant ? Vous pouvez également le faire plus tard dans les paramètres.',
'speech.signup.setup_contacts': 'Configurer les Contacts',
'speech.signup.skip_for_now': 'Ignorer pour l\'Instant',
'speech.signup.company_required': 'Le nom de l\'entreprise est requis',
'speech.signup.industry_required': 'Le secteur d\'activité est requis',
'speech.signup.email_required': 'L\'adresse email est requise',
'speech.signup.email_invalid': 'Veuillez entrer une adresse email valide',
'speech.signup.phone_required': 'Le numéro de téléphone est requis',
'speech.signup.street_required': 'La rue est requise',
'speech.signup.postal_code_required': 'Le code postal est requis',
'speech.signup.city_required': 'La ville est requise',
'speech.signup.country_required': 'Le pays est requis',
'speech.status.submitted': '✓ Mandat Soumis',
'speech.status.reset': 'Recommencer',
'speech.confirmation.title': 'Mandat Soumis avec Succès !',
'speech.confirmation.message': 'Merci pour votre intérêt pour notre Intégration Vocale powered by Spitch.ai. Nous avons reçu votre mandat et l\'examinerons sous peu.',
'speech.confirmation.submitted_data': 'Données Soumises :',
'speech.confirmation.company': 'Entreprise',
'speech.confirmation.industry': 'Secteur',
'speech.confirmation.email': 'Email',
'speech.confirmation.phone': 'Téléphone',
'speech.confirmation.address': 'Adresse',
'speech.confirmation.timezone': 'Fuseau Horaire',
'speech.confirmation.back': 'Retour à l\'Intégration Vocale',
'speech.confirmation.reset': 'Recommencer',
'speech.confirmation.next_steps': 'Que se passe-t-il ensuite ?',
'speech.confirmation.email_confirmation': 'Confirmation par Email',
'speech.confirmation.email_confirmation_desc': 'Vous recevrez un email de confirmation dans les prochaines minutes.',
'speech.confirmation.review_process': 'Processus de Révision',
'speech.confirmation.review_process_desc': 'Notre équipe examinera votre mandat dans les 1-2 jours ouvrables.',
'speech.confirmation.setup_call': 'Appel de Configuration',
'speech.confirmation.setup_call_desc': 'Si approuvé, nous planifierons un appel de configuration pour configurer votre intégration.',
'speech.confirmation.questions': 'Questions ?',
'speech.confirmation.questions_desc': 'Si vous avez des questions sur votre mandat ou le processus d\'intégration, n\'hésitez pas à contacter notre équipe de support.',
'speech.confirmation.transcript_management': 'Gestion des Transcriptions',
'speech.confirmation.speech_settings': 'Paramètres Vocaux',
'speech.transcripts.title': 'Gestion des Transcriptions',
'speech.transcripts.new_transcript': 'Nouvelle Transcription',
'speech.transcripts.recent_transcripts': 'Transcriptions Récentes',
'speech.transcripts.no_transcripts': 'Aucune transcription disponible',
'speech.transcripts.date': 'Date',
'speech.transcripts.duration': 'Durée',
'speech.transcripts.status': 'Statut',
'speech.transcripts.transcript': 'Transcription',
'speech.transcripts.processing': 'Traitement de la transcription...',
'speech.transcripts.status.completed': 'Terminé',
'speech.transcripts.status.processing': 'En cours',
'speech.transcripts.status.failed': 'Échoué',
'speech.transcripts.access_denied_title': 'Accès Refusé',
'speech.transcripts.access_denied_message': 'Vous devez d\'abord vous inscrire à l\'intégration vocale pour accéder à la gestion des transcriptions.',
'speech.transcripts.sign_up_now': 'S\'inscrire Maintenant',
'speech.transcripts.subject': 'Sujet',
'speech.transcripts.start_time': 'Heure de Début',
'speech.transcripts.end_time': 'Heure de Fin',
'speech.transcripts.caller': 'Appelant',
'speech.transcripts.recipient': 'Destinataire',
'speech.transcripts.tags': 'Étiquettes',
'speech.transcripts.created': 'Créé',
'speech.transcripts.view': 'Voir',
'speech.transcripts.download': 'Télécharger',
'speech.settings.title': 'Paramètres d\'Intégration Vocale',
'speech.settings.description': 'Gérez votre configuration et vos préférences d\'intégration vocale.',
'speech.settings.company_info': 'Informations de l\'Entreprise',
'speech.settings.contact_info': 'Informations de Contact',
'speech.settings.business_hours': 'Heures d\'Ouverture et Fuseau Horaire',
'speech.settings.save': 'Sauvegarder les Modifications',
'speech.settings.saving': 'Sauvegarde...',
'speech.settings.save_success': 'Paramètres sauvegardés avec succès !',
'speech.settings.save_error': 'Échec de la sauvegarde des paramètres. Veuillez réessayer.',
'speech.settings.reset': 'Réinitialiser par Défaut',
'speech.settings.reset_confirm': 'Êtes-vous sûr de vouloir réinitialiser tous les paramètres d\'intégration vocale ? Cette action ne peut pas être annulée.',
'speech.settings.reset_success': 'Les paramètres ont été réinitialisés avec succès.',
'speech.settings.no_data': 'Aucune donnée d\'intégration vocale trouvée. Veuillez d\'abord vous inscrire pour accéder aux paramètres.',
'speech.settings.sign_up_now': 'S\'inscrire Maintenant',
};

View file

@ -1,14 +1,18 @@
import { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import styles from './HomeStyles/Einstellungen.module.css';
import sharedStyles from '../../components/PageManager/pages.module.css';
import { useLanguage, Language } from '../../contexts/LanguageContext';
import { useCurrentUser, useUser, User } from '../../hooks/useUsers';
import SettingsSpeech from '../../components/settings/settingsSpeech';
function Einstellungen() {
console.log('🏠 Einstellungen component loaded');
const [isDarkMode, setIsDarkMode] = useState(false);
const { currentLanguage, setLanguage, t, isLoading } = useLanguage();
const { user: currentUser, isLoading: currentUserLoading } = useCurrentUser();
const { getUser, updateUser, isLoading: updateLoading } = useUser();
const navigate = useNavigate();
// Local state for user data fetched directly via API
const [user, setUser] = useState<User | null>(null);
@ -28,6 +32,9 @@ function Einstellungen() {
const [isUpdating, setIsUpdating] = useState(false); // Flag to prevent form reset during update
const hasLoadedUser = useRef(false);
// Speech integration state
const [hasSpeechIntegration, setHasSpeechIntegration] = useState(false);
// Fetch user data directly using the /api/users/{userId} endpoint
const fetchUserData = async () => {
if (!currentUser?.id || hasLoadedUser.current) return;
@ -48,11 +55,43 @@ function Einstellungen() {
}
};
// Check for speech integration data
const checkSpeechIntegration = () => {
try {
const savedData = localStorage.getItem('speechSignUpData');
const timestamp = localStorage.getItem('speechSignUpTimestamp');
if (savedData && timestamp) {
const savedTime = parseInt(timestamp);
const now = Date.now();
const twentyFourHours = 24 * 60 * 60 * 1000;
// Check if data is still valid (within 24 hours)
if (now - savedTime < twentyFourHours) {
setHasSpeechIntegration(true);
} else {
// Data expired, clear it
localStorage.removeItem('speechSignUpData');
localStorage.removeItem('speechSignUpTimestamp');
setHasSpeechIntegration(false);
}
} else {
setHasSpeechIntegration(false);
}
} catch (error) {
console.error('Error checking speech integration data:', error);
setHasSpeechIntegration(false);
}
};
// Sync component state with current theme on mount
useEffect(() => {
const savedTheme = localStorage.getItem('theme');
const prefersDark = savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches);
setIsDarkMode(prefersDark);
// Check for speech integration data
checkSpeechIntegration();
}, []);
// Fetch user data when currentUser is available
@ -77,6 +116,109 @@ function Einstellungen() {
}
}, [user, currentLanguage, isUpdating]);
// Listen for speech integration updates
useEffect(() => {
const handleSpeechUpdate = () => {
checkSpeechIntegration();
};
window.addEventListener('speechSignUpChanged', handleSpeechUpdate);
return () => {
window.removeEventListener('speechSignUpChanged', handleSpeechUpdate);
};
}, []);
// Handle hash navigation to speech settings
useEffect(() => {
const handleHashChange = () => {
console.log('🔍 Current hash:', window.location.hash);
if (window.location.hash === '#speech-settings') {
console.log('🔗 Hash navigation detected: #speech-settings');
// Wait for the component to render, then scroll
const scrollToElement = () => {
const element = document.getElementById('speech-settings');
console.log('🎯 Looking for element with id="speech-settings":', element);
if (element) {
console.log('✅ Element found, scrolling to it');
element.scrollIntoView({ behavior: 'smooth' });
return true;
}
console.log('❌ Element not found');
return false;
};
// Try immediately, then with increasing delays
if (!scrollToElement()) {
setTimeout(() => {
if (!scrollToElement()) {
setTimeout(() => scrollToElement(), 200);
}
}, 100);
}
}
};
// Check hash on mount with a delay to ensure component is ready
const checkHashOnMount = () => {
console.log('🚀 Initial hash check:', window.location.hash);
if (window.location.hash === '#speech-settings') {
console.log('🚀 Initial hash check: #speech-settings');
// Wait for component to be ready
setTimeout(() => {
handleHashChange();
}, 100);
}
};
checkHashOnMount();
// Listen for hash changes
window.addEventListener('hashchange', handleHashChange);
return () => {
window.removeEventListener('hashchange', handleHashChange);
};
}, []); // Remove hasSpeechIntegration dependency to avoid re-running
// Additional effect to handle hash navigation after speech integration status changes
useEffect(() => {
if (window.location.hash === '#speech-settings') {
console.log('🔄 Speech integration status changed, checking hash navigation');
// Wait a bit for the component to re-render with new content
setTimeout(() => {
const element = document.getElementById('speech-settings');
console.log('🎯 Re-checking element after status change:', element);
if (element) {
console.log('✅ Element found after status change, scrolling to it');
element.scrollIntoView({ behavior: 'smooth' });
} else {
console.log('❌ Element still not found after status change');
}
}, 50);
}
}, [hasSpeechIntegration]);
// Effect to handle hash navigation when component mounts
useEffect(() => {
const handleInitialHash = () => {
if (window.location.hash === '#speech-settings') {
console.log('🚀 Component mounted with hash: #speech-settings');
// Wait for everything to be ready
setTimeout(() => {
const element = document.getElementById('speech-settings');
console.log('🎯 Initial mount element check:', element);
if (element) {
console.log('✅ Element found on mount, scrolling to it');
element.scrollIntoView({ behavior: 'smooth' });
} else {
console.log('❌ Element not found on mount');
}
}, 200);
}
};
handleInitialHash();
}, []);
const applyTheme = (isDark: boolean) => {
if (isDark) {
document.documentElement.classList.add('dark-theme');
@ -376,6 +518,22 @@ function Einstellungen() {
</button>
</div>
{/* Speech Integration Settings */}
<div id="speech-settings" className={styles.speechSettingsSection}>
{hasSpeechIntegration ? (
<SettingsSpeech />
) : (
<div className={styles.noSpeechData}>
<p>{t('speech.settings.no_data')}</p>
<button
className={sharedStyles.primaryButton}
onClick={() => navigate('/speech')}
>
{t('speech.settings.sign_up_now')}
</button>
</div>
)}
</div>
</div>
</div>

View file

@ -293,4 +293,25 @@
width: 100%;
max-width: 200px;
}
}
.speechSettingsSection {
margin-top: 2rem;
padding-top: 2rem;
border-top: 2px solid var(--color-secondary);
}
.noSpeechData {
text-align: center;
padding: 2rem;
background: var(--color-bg);
border-radius: 20px;
border: 1px solid var(--color-secondary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.noSpeechData p {
margin: 0 0 1.5rem 0;
color: var(--color-text);
font-size: 1rem;
}

View file

@ -0,0 +1,52 @@
.partnerLogo {
height: 45px;
width: auto;
margin-left: 0.5rem;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
transition: transform 0.2s ease;
}
.partnerLogo:hover {
transform: scale(1.05);
}
.submittedStatus {
display: flex;
align-items: center;
gap: 1rem;
}
.statusText {
color: var(--color-secondary);
font-weight: 500;
font-size: 0.9rem;
}
.resetButton {
padding: 0.5rem 1rem;
background: var(--color-bg);
border: 1px solid var(--color-secondary);
color: var(--color-secondary);
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.resetButton:hover {
background: var(--color-secondary);
color: var(--color-bg);
}
@media (max-width: 768px) {
.partnerLogo {
height: 20px;
}
.submittedStatus {
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
}

View file

@ -0,0 +1,293 @@
.content {
display: flex;
gap: 2rem;
height: calc(100vh - 200px);
overflow: hidden;
}
.transcriptList {
flex: 1;
overflow-y: auto;
padding-right: 1rem;
}
.transcriptList h2 {
font-size: 1.2rem;
font-weight: bold;
color: var(--color-secondary);
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--color-secondary);
}
.transcriptGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.transcriptCard {
background: var(--color-bg);
border: 1px solid var(--color-secondary);
border-radius: 20px;
padding: 1.25rem;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.transcriptCard:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: var(--color-secondary);
}
.transcriptCard.selected {
border-color: var(--color-secondary);
background: var(--color-secondary);
color: white;
}
.transcriptHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.transcriptTitle {
font-size: 1rem;
font-weight: bold;
margin: 0;
color: var(--color-text);
flex: 1;
margin-right: 0.5rem;
}
.transcriptCard.selected .transcriptTitle {
color: white;
}
.status {
padding: 0.25rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status.completed {
background: #10b981;
color: white;
}
.status.processing {
background: #f59e0b;
color: white;
}
.status.failed {
background: #ef4444;
color: white;
}
.transcriptMeta {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
color: var(--color-text);
}
.transcriptCard.selected .transcriptMeta {
color: rgba(255, 255, 255, 0.8);
}
.date, .duration {
font-weight: 500;
}
.transcriptDetails {
flex: 1;
background: var(--color-bg);
border: 1px solid var(--color-secondary);
border-radius: 20px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
overflow: hidden;
}
.detailsHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--color-secondary);
}
.detailsHeader h3 {
font-size: 1.3rem;
font-weight: bold;
color: var(--color-text);
margin: 0;
flex: 1;
}
.closeButton {
background: none;
border: none;
font-size: 1.5rem;
color: var(--color-text);
cursor: pointer;
padding: 0.25rem;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.closeButton:hover {
background: var(--color-secondary);
color: white;
}
.detailsContent {
flex: 1;
overflow-y: auto;
}
.metaInfo {
margin-bottom: 1.5rem;
}
.metaItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-primary);
}
.metaItem:last-child {
border-bottom: none;
}
.metaItem strong {
color: var(--color-text);
font-weight: 600;
}
.metaItem span {
color: var(--color-text);
}
.transcriptContent {
background: var(--color-bg);
border: 1px solid var(--color-primary);
border-radius: 15px;
padding: 1rem;
}
.transcriptContent h4 {
font-size: 1rem;
font-weight: bold;
color: var(--color-secondary);
margin: 0 0 0.75rem 0;
}
.transcriptContent p {
color: var(--color-text);
line-height: 1.6;
margin: 0;
white-space: pre-wrap;
}
.processingMessage {
text-align: center;
padding: 2rem;
color: var(--color-text);
}
.processingMessage p {
font-style: italic;
margin: 0;
}
.emptyState {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text);
}
.emptyState p {
font-size: 1.1rem;
margin: 0;
}
.accessDenied {
text-align: center;
padding: 3rem 2rem;
background: var(--color-bg);
border: 1px solid var(--color-secondary);
border-radius: 20px;
margin: 2rem 0;
}
.accessDenied h2 {
font-size: 1.5rem;
font-weight: bold;
color: var(--color-text);
margin: 0 0 1rem 0;
}
.accessDenied p {
color: var(--color-text);
margin: 0 0 2rem 0;
line-height: 1.6;
}
@media (max-width: 1024px) {
.content {
flex-direction: column;
height: auto;
}
.transcriptList {
overflow-y: visible;
padding-right: 0;
}
.transcriptDetails {
margin-top: 1rem;
}
}
@media (max-width: 640px) {
.transcriptGrid {
grid-template-columns: 1fr;
}
.transcriptCard {
padding: 1rem;
}
.transcriptDetails {
padding: 1rem;
}
.detailsHeader h3 {
font-size: 1.1rem;
}
.metaItem {
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
}
}

160
src/pages/Home/Speech.tsx Normal file
View file

@ -0,0 +1,160 @@
import React, { useState } from 'react';
import sharedStyles from '../../components/PageManager/pages.module.css';
import { SpeechInfo, SpeechSignUp, SpeechConfirmation } from '../../components/Speech';
import { useLanguage } from '../../contexts/LanguageContext';
import SpitchLogo from '/logos/spitch-logo.svg';
import styles from './HomeStyles/Speech.module.css';
type SpeechPage = 'info' | 'signup' | 'confirmation';
interface MandateData {
id: string;
mandate_general: {
company_name: string;
industry: string;
contact_info: {
email: string;
phone: string;
street: string;
postal_code: string;
city: string;
country: string;
};
business_hours: string;
timezone: string;
};
setup_contacts: boolean;
}
function Speech() {
const { t } = useLanguage();
const [currentPage, setCurrentPage] = useState<SpeechPage>('info');
const [hasSubmitted, setHasSubmitted] = useState<boolean>(false);
const [submittedData, setSubmittedData] = useState<MandateData | null>(null);
// Check for existing sign-up data on component mount
React.useEffect(() => {
try {
const savedData = localStorage.getItem('speechSignUpData');
const savedTimestamp = localStorage.getItem('speechSignUpTimestamp');
if (savedData && savedTimestamp) {
const data = JSON.parse(savedData);
const timestamp = parseInt(savedTimestamp);
const now = Date.now();
// Check if data is from current session (within last 24 hours)
const hoursDiff = (now - timestamp) / (1000 * 60 * 60);
if (hoursDiff < 24) {
setHasSubmitted(true);
setSubmittedData(data);
setCurrentPage('confirmation');
} else {
// Clear old data
localStorage.removeItem('speechSignUpData');
localStorage.removeItem('speechSignUpTimestamp');
}
}
} catch (error) {
console.error('Failed to load saved sign-up data:', error);
}
}, []);
const handleSignUp = () => {
setCurrentPage('signup');
};
const handleSignUpSubmit = (data: MandateData) => {
// Save to localStorage for persistence
try {
localStorage.setItem('speechSignUpData', JSON.stringify(data));
localStorage.setItem('speechSignUpTimestamp', Date.now().toString());
console.log('Sign-up data saved to localStorage:', data);
// Dispatch custom event to refresh sidebar
window.dispatchEvent(new CustomEvent('speechSignUpChanged'));
} catch (error) {
console.error('Failed to save sign-up data:', error);
}
setSubmittedData(data);
setHasSubmitted(true);
setCurrentPage('confirmation');
console.log('Mandate data submitted:', data);
};
const handleBackToInfo = () => {
setCurrentPage('info');
};
const handleResetSignUp = () => {
// Clear localStorage and reset state
try {
localStorage.removeItem('speechSignUpData');
localStorage.removeItem('speechSignUpTimestamp');
setHasSubmitted(false);
setSubmittedData(null);
setCurrentPage('info');
// Dispatch custom event to refresh sidebar
window.dispatchEvent(new CustomEvent('speechSignUpChanged'));
} catch (error) {
console.error('Failed to clear saved data:', error);
}
};
return (
<div className={sharedStyles.pageContainer}>
<div className={sharedStyles.pageCard}>
<div className={sharedStyles.pageHeader}>
<h1 className={sharedStyles.pageTitle}>{t('speech.title')}</h1>
{currentPage === 'info' && !hasSubmitted && (
<button
className={sharedStyles.primaryButton}
onClick={handleSignUp}
>
{t('speech.signup.button')}
</button>
)}
{hasSubmitted && (
<div className={styles.submittedStatus}>
<span className={styles.statusText}>
{t('speech.status.submitted')}
</span>
<button
className={styles.resetButton}
onClick={handleResetSignUp}
>
{t('speech.status.reset')}
</button>
</div>
)}
</div>
<div className={sharedStyles.pageSubtitle}>
<span>{t('speech.subtitle')}</span>
<img src={SpitchLogo} alt="Spitch.ai" className={styles.partnerLogo} />
</div>
{currentPage === 'info' && (
<SpeechInfo />
)}
{currentPage === 'signup' && (
<SpeechSignUp
onBack={handleBackToInfo}
onSubmit={handleSignUpSubmit}
/>
)}
{currentPage === 'confirmation' && (
<SpeechConfirmation
onBackToInfo={handleBackToInfo}
submittedData={submittedData}
onReset={handleResetSignUp}
/>
)}
</div>
</div>
);
}
export default Speech;

View file

@ -0,0 +1,262 @@
import { useState, useEffect } from 'react';
import sharedStyles from '../../components/PageManager/pages.module.css';
import { useLanguage } from '../../contexts/LanguageContext';
import { FormGenerator, ColumnConfig } from '../../components/FormGenerator/FormGenerator';
import { IoIosEye, IoIosDownload } from 'react-icons/io';
interface CallTranscript {
id: string;
mandateId: string;
start_datetime: string;
finish_datetime: string;
caller_phone: string;
recipient_phone: string;
transcript_text: string;
subject: string;
tags: string[];
spitch_call_id: string;
created_at: string;
created_by: string;
}
function SpeechTranscripts() {
const { t } = useLanguage();
const [transcripts, setTranscripts] = useState<CallTranscript[]>([]);
const [hasSignedUp, setHasSignedUp] = useState<boolean>(false);
// Check if user has signed up for speech integration
useEffect(() => {
try {
const savedData = localStorage.getItem('speechSignUpData');
const timestamp = localStorage.getItem('speechSignUpTimestamp');
if (savedData && timestamp) {
const signUpTime = parseInt(timestamp);
const now = Date.now();
const hoursDiff = (now - signUpTime) / (1000 * 60 * 60);
// Data is valid for 24 hours
if (hoursDiff < 24) {
setHasSignedUp(true);
}
}
} catch (error) {
console.error('Error checking speech sign-up status:', error);
}
}, []);
// Column configuration for FormGenerator
const columns: ColumnConfig[] = [
{
key: 'subject',
label: t('speech.transcripts.subject', 'Subject'),
type: 'string',
width: 200,
sortable: true,
filterable: true,
searchable: true
},
{
key: 'start_datetime',
label: t('speech.transcripts.start_time', 'Start Time'),
type: 'date',
width: 180,
sortable: true,
filterable: true,
searchable: false
},
{
key: 'finish_datetime',
label: t('speech.transcripts.end_time', 'End Time'),
type: 'date',
width: 180,
sortable: true,
filterable: true,
searchable: false
},
{
key: 'caller_phone',
label: t('speech.transcripts.caller', 'Caller'),
type: 'string',
width: 150,
sortable: true,
filterable: true,
searchable: true
},
{
key: 'recipient_phone',
label: t('speech.transcripts.recipient', 'Recipient'),
type: 'string',
width: 150,
sortable: true,
filterable: true,
searchable: true
},
{
key: 'tags',
label: t('speech.transcripts.tags', 'Tags'),
type: 'string',
width: 200,
sortable: false,
filterable: true,
searchable: true,
formatter: (value: string[]) => value.join(', ')
},
{
key: 'created_at',
label: t('speech.transcripts.created', 'Created'),
type: 'date',
width: 150,
sortable: true,
filterable: true,
searchable: false
}
];
// Mock data for demonstration
useEffect(() => {
if (hasSignedUp) {
setTranscripts([
{
id: '1',
mandateId: 'mandate-001',
start_datetime: '2024-01-15T10:30:00Z',
finish_datetime: '2024-01-15T11:15:00Z',
caller_phone: '+41 987 654 321',
recipient_phone: '+41 123 456 789',
transcript_text: 'This is a sample transcript content for a client meeting discussing project requirements and next steps...',
subject: 'Client Meeting - Project Discussion',
tags: ['client', 'meeting', 'project'],
spitch_call_id: 'spitch-call-uuid-123',
created_at: '2024-01-15T10:30:00Z',
created_by: 'system'
},
{
id: '2',
mandateId: 'mandate-001',
start_datetime: '2024-01-14T09:00:00Z',
finish_datetime: '2024-01-14T09:15:00Z',
caller_phone: '+41 987 654 322',
recipient_phone: '+41 123 456 790',
transcript_text: 'This is another sample transcript content for a team standup meeting covering daily updates and blockers...',
subject: 'Team Standup Meeting',
tags: ['team', 'standup', 'internal'],
spitch_call_id: 'spitch-call-uuid-124',
created_at: '2024-01-14T09:00:00Z',
created_by: 'system'
},
{
id: '3',
mandateId: 'mandate-001',
start_datetime: '2024-01-13T14:20:00Z',
finish_datetime: '2024-01-13T14:52:00Z',
caller_phone: '+41 987 654 323',
recipient_phone: '+41 123 456 791',
transcript_text: 'Customer support call regarding billing inquiry and service upgrade options...',
subject: 'Customer Support Call',
tags: ['support', 'billing', 'customer'],
spitch_call_id: 'spitch-call-uuid-125',
created_at: '2024-01-13T14:20:00Z',
created_by: 'system'
},
{
id: '4',
mandateId: 'mandate-001',
start_datetime: '2024-01-12T16:45:00Z',
finish_datetime: '2024-01-12T17:30:00Z',
caller_phone: '+41 987 654 324',
recipient_phone: '+41 123 456 792',
transcript_text: 'Technical consultation call about system integration and API documentation...',
subject: 'Technical Consultation',
tags: ['technical', 'consultation', 'integration'],
spitch_call_id: 'spitch-call-uuid-126',
created_at: '2024-01-12T16:45:00Z',
created_by: 'system'
},
{
id: '5',
mandateId: 'mandate-001',
start_datetime: '2024-01-11T11:10:00Z',
finish_datetime: '2024-01-11T11:25:00Z',
caller_phone: '+41 987 654 325',
recipient_phone: '+41 123 456 793',
transcript_text: 'Quick follow-up call to confirm meeting details and deliverables...',
subject: 'Follow-up Call',
tags: ['follow-up', 'meeting', 'confirmation'],
spitch_call_id: 'spitch-call-uuid-127',
created_at: '2024-01-11T11:10:00Z',
created_by: 'system'
}
]);
}
}, [hasSignedUp]);
if (!hasSignedUp) {
return (
<div className={sharedStyles.pageContainer}>
<div className={sharedStyles.pageCard}>
<div className={sharedStyles.pageHeader}>
<h1 className={sharedStyles.pageTitle}>{t('speech.transcripts.title')}</h1>
</div>
<div className={sharedStyles.pageContent}>
<h2>{t('speech.transcripts.access_denied_title')}</h2>
<p>{t('speech.transcripts.access_denied_message')}</p>
<button
className={sharedStyles.primaryButton}
onClick={() => window.location.href = '/speech'}
>
{t('speech.transcripts.sign_up_now')}
</button>
</div>
</div>
</div>
);
}
return (
<div className={sharedStyles.pageContainer}>
<div className={sharedStyles.pageCard}>
<div className={sharedStyles.pageHeader}>
<h1 className={sharedStyles.pageTitle}>{t('speech.transcripts.title')}</h1>
</div>
<FormGenerator
data={transcripts}
columns={columns}
title={t('speech.transcripts.recent_transcripts')}
searchable={true}
filterable={true}
sortable={true}
resizable={true}
pagination={true}
pageSize={10}
selectable={true}
onRowClick={(transcript) => {
// Handle row click - could open a modal with transcript details
console.log('Selected transcript:', transcript);
}}
actions={[
{
label: t('speech.transcripts.view', 'View'),
onClick: (transcript) => {
// Handle view action
console.log('View transcript:', transcript);
},
icon: <IoIosEye />
},
{
label: t('speech.transcripts.download', 'Download'),
onClick: (transcript) => {
// Handle download action
console.log('Download transcript:', transcript);
},
icon: <IoIosDownload />
}
]}
/>
</div>
</div>
);
}
export default SpeechTranscripts;

View file

@ -57,12 +57,18 @@ function Login() {
}
};
const handleCredentialLogin = async () => {
const handleCredentialLogin = async (e?: React.MouseEvent) => {
e?.preventDefault(); // Prevent default form submission
try {
console.log("Attempting login with:", username);
await login(username, password);
console.log("Login successful, navigating to:", from);
// Only navigate if login was successful
navigate(from, { replace: true });
} catch (error) {
console.error("Login failed:", error);
// Stay on login page to show error message
// The error will be displayed via the loginError state from useAuth hook
}
};
@ -89,6 +95,12 @@ function Login() {
onChange={(e) => setUsername(e.target.value)}
onFocus={() => setUsernameFocused(true)}
onBlur={() => setUsernameFocused(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCredentialLogin();
}
}}
className={`${styles.input} ${usernameFocused || username ? styles.focused : ''}`}
/>
<label className={usernameFocused || username ? styles.focusedLabel : styles.label}>Benutzername</label>
@ -101,6 +113,12 @@ function Login() {
onChange={(e) => setPassword(e.target.value)}
onFocus={() => setPasswordFocused(true)}
onBlur={() => setPasswordFocused(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCredentialLogin();
}
}}
className={`${styles.input} ${passwordFocused || password ? styles.focused : ''}`}
/>
<label className={passwordFocused || password ? styles.focusedLabel : styles.label}>Passwort</label>