Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.15.0] - 2026-07-06

### Added

- `AppZumDocLogo` component with the official App zum Doc logo, configurable `frontColor`, `backColor`, and `size`, and an `animate` prop (`loading` fills both logo parts clockwise, plus `pulse` and `bounce`)
- `AppZumDocBadge` component with the App zum Doc logo and wordmark, mirroring `HelpwaveBadge`
- Chat component set for the App zum Doc messaging surfaces, styled via class names with `className` forwarding on every component:
- `ChatLayout` two-pane layout (conversation list + open thread) with responsive single-pane behavior below the tablet breakpoint, a `listPosition` option (`left`/`right`), no default border or rounding, nestable into `AppPage`
- `ChatThread` column layout with `header` and `footer` slots
- `ChatConversationList` with optional `title`, optional create button (`onCreate`), toggleable built-in `SearchBar` (`hasSearch`, `onSearch`), and `header`/`footer` slots around a scrollable row area
- `ChatConversationRow` with avatar presence, timestamp, preview, sent indicator, unread count pill, and `selected`/`unread` states
- `ChatThreadHeader` with avatar presence, title, subtitle, optional action buttons rendered only when their handler is set (`onBack`, `onCall`, `onAddContact`), and `leading`/`trailing` slots
- `ChatMessageList` scrollable message pane with optional `autoScroll`
- `ChatMessageBubble` incoming/outgoing bubble with asymmetric radii, inline timestamp, and optional read receipt
- `ChatMessageCard` structured message card (e.g. appointment proposal, prescription request, referral) with icon, title, subtitle, badge, body, and action footer
- `ChatAttachmentCard` with file icon, filename, metadata, and a download action shown only when `onDownload` is set
- `ChatComposer` with controlled or uncontrolled value, optional camera and attachment buttons rendered only when their handler is set (`onCamera`, `onAttachment`), `leading`/`trailing` slots, and enter-to-send
- `ChatQuickReplyChip` outlined pill button with active state
- `ChatSystemLine` centered status line with icon
- `ChatDateDivider` centered date pill
- `time` mode for `TimeDisplay` rendering only the localized time, usable for chat timestamps
- Unit tests for the chat component set and the App zum Doc branding components

## [0.14.5] - 2026-07-06

### Added
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"access": "public"
},
"license": "MPL-2.0",
"version": "0.14.5",
"version": "0.15.0",
"browserslist": [
"> 0.2%",
"last 3 versions",
Expand Down
32 changes: 32 additions & 0 deletions src/components/branding/AppZumDocBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { HTMLAttributes } from 'react'
import clsx from 'clsx'
import { AppZumDocLogo } from './AppZumDocLogo'

type Size = 'sm' | 'md' | 'lg'

export type AppZumDocBadgeProps = HTMLAttributes<HTMLSpanElement> & {
size?: Size,
}

export const AppZumDocBadge = ({
size = 'sm',
...props
}: AppZumDocBadgeProps) => {
return (
<span
{...props}
className={clsx(
'flex flex-row items-center font-bold font-space rounded-md text-[#057986]',
{
'gap-x-1': size === 'sm',
'gap-x-2 text-lg': size === 'md',
'gap-x-2 text-xl': size === 'lg'
},
props.className
)}
>
<AppZumDocLogo size={size}/>
App zum Doc
</span>
)
}
96 changes: 96 additions & 0 deletions src/components/branding/AppZumDocLogo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { CSSProperties, SVGProps } from 'react'
import { useId } from 'react'
import { clsx } from 'clsx'

const backFillPath = 'M33.1817 37.511L29.7804 37.511C21.7695 37.511 15.2754 44.0051 15.2754 52.016C15.2754 60.0268 21.7695 66.5209 29.7804 66.5209H35.4017C35.4208 66.5209 35.4274 66.5239 35.4301 66.5251C35.436 66.5275 35.4456 66.5331 35.4559 66.5434C35.4662 66.5536 35.4717 66.5633 35.4742 66.5691C35.4754 66.5719 35.4783 66.5785 35.4783 66.5975L35.4783 72.852C35.4783 80.8628 41.9724 87.3569 49.9833 87.3569C57.9942 87.3569 64.4883 80.8628 64.4883 72.852L64.4883 54.7684C64.4883 45.2374 56.7619 37.511 47.2309 37.511L43.6515 37.511V42.1072L47.2309 42.1072C54.2235 42.1072 59.8921 47.7758 59.8921 54.7684V72.852C59.8921 78.3244 55.4558 82.7608 49.9833 82.7608C44.5108 82.7608 40.0745 78.3244 40.0745 72.852L40.0745 66.5975C40.0745 64.0168 37.9824 61.9248 35.4017 61.9248L29.7804 61.9248C24.3079 61.9248 19.8716 57.4884 19.8716 52.016C19.8716 46.5435 24.3079 42.1072 29.7804 42.1072L33.1817 42.1072V37.511Z'

const frontFillPath = 'M65.0734 29.3412C65.0734 21.3303 58.5793 14.8362 50.5685 14.8362C42.5576 14.8362 36.0635 21.3303 36.0635 29.3412V47.4247C36.0635 56.9557 43.7899 64.6821 53.3209 64.6821H57.4673V60.086H53.3209C46.3283 60.086 40.6597 54.4173 40.6597 47.4247V29.3412C40.6597 23.8687 45.096 19.4324 50.5685 19.4324C56.0409 19.4324 60.4773 23.8687 60.4773 29.3412V35.5956C60.4773 38.1763 62.5693 40.2684 65.15 40.2684H70.7714C76.2439 40.2684 80.6802 44.7047 80.6802 50.1772C80.6802 55.6496 76.2439 60.086 70.7714 60.086H66.7949V64.6821H70.7714C78.7823 64.6821 85.2764 58.188 85.2764 50.1772C85.2764 42.1663 78.7823 35.6722 70.7714 35.6722H65.15C65.131 35.6722 65.1244 35.6692 65.1216 35.668C65.1158 35.6656 65.1061 35.66 65.0959 35.6497C65.0856 35.6395 65.08 35.6298 65.0776 35.624L65.0775 35.6238C65.0763 35.6209 65.0734 35.6142 65.0734 35.5956V29.3412Z'

const backStrokePath = 'M43.65 39.81 L47.23 39.81 A14.96 14.96 0 0 1 62.19 54.77 L62.19 72.85 A12.21 12.21 0 0 1 37.78 72.85 L37.78 66.6 Q37.78 64.22 35.4 64.22 L29.78 64.22 A12.21 12.21 0 0 1 29.78 39.81 L33.18 39.81'

const frontStrokePath = 'M57.47 62.38 L53.32 62.38 A14.96 14.96 0 0 1 38.36 47.42 L38.36 29.34 A12.21 12.21 0 0 1 62.78 29.34 L62.78 35.6 Q62.78 37.97 65.15 37.97 L70.77 37.97 A12.21 12.21 0 0 1 70.77 62.38 L66.79 62.38'

export type AppZumDocLogoProps = SVGProps<SVGSVGElement> & {
frontColor?: string,
backColor?: string,
animate?: 'none' | 'loading' | 'pulse' | 'bounce',
size?: 'sm' | 'md' | 'lg',
animationDuration?: number,
}

export const AppZumDocLogo = ({
frontColor = '#095763',
backColor = '#4E97A2',
animate = 'none',
size,
animationDuration = 1.7,
...props
}: AppZumDocLogoProps) => {
const id = useId()
const isLoadingAnimation = animate === 'loading'
let svgAnimationKey = ''

if (animate === 'pulse') {
svgAnimationKey = 'animate-pulse'
} else if (animate === 'bounce') {
svgAnimationKey = 'animate-bounce'
}

const style = animationDuration !== undefined && isLoadingAnimation
? { ...props.style, '--helpwave-loading-duration': `${animationDuration}s` } as CSSProperties
: props.style

return (
<svg
{...props}
style={style}
viewBox="14 14 72 74"
fill="none"
className={clsx({
'max-w-16 max-h-16 min-w-16 min-h-16': size === 'lg',
'max-w-12 max-h-12 min-w-12 min-h-12': size === 'md',
'max-w-8 max-h-8 min-w-8 min-h-8': size === 'sm',
}, props.className)}
>
<g className={clsx(svgAnimationKey)}>
{isLoadingAnimation ? (
<>
<clipPath id={`${id}-back`}>
<path d={backFillPath}/>
</clipPath>
<clipPath id={`${id}-front`}>
<path d={frontFillPath}/>
</clipPath>
<g clipPath={`url(#${id}-back)`}>
<path
className="animate-azd-fill-back"
d={backStrokePath}
stroke={backColor}
strokeWidth={7}
strokeLinecap="round"
pathLength={1000}
strokeDasharray="1000 2000"
/>
</g>
<g clipPath={`url(#${id}-front)`}>
<path
className="animate-azd-fill-front"
d={frontStrokePath}
stroke={frontColor}
strokeWidth={7}
strokeLinecap="round"
pathLength={1000}
strokeDasharray="1000 2000"
/>
</g>
</>
) : (
<>
<path fillRule="evenodd" clipRule="evenodd" d={backFillPath} fill={backColor}/>
<path fillRule="evenodd" clipRule="evenodd" d={frontFillPath} fill={frontColor}/>
</>
)}
</g>
</svg>
)
}
53 changes: 53 additions & 0 deletions src/components/chat/ChatAttachmentCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { HTMLAttributes, ReactNode } from 'react'
import clsx from 'clsx'
import { Download, FileText } from 'lucide-react'
import { IconButton } from '../user-interaction/IconButton'
import type { ChatMessageDirection } from './ChatMessageBubble'

export type ChatAttachmentCardProps = HTMLAttributes<HTMLDivElement> & {
name: ReactNode,
metadata?: ReactNode,
icon?: ReactNode,
direction?: ChatMessageDirection,
downloadLabel?: string,
onDownload?: () => void,
}

export const ChatAttachmentCard = ({
name,
metadata,
icon,
direction = 'incoming',
downloadLabel = 'Download',
onDownload,
...props
}: ChatAttachmentCardProps) => {
return (
<div
{...props}
className={clsx('chat-attachment-card', props.className)}
data-direction={direction}
>
<span className="chat-attachment-card-icon">
{icon ?? <FileText/>}
</span>
<span className="chat-attachment-card-info">
<span className="chat-attachment-card-filename">{name}</span>
{metadata && (
<span className="chat-attachment-card-metadata">{metadata}</span>
)}
</span>
{onDownload && (
<IconButton
tooltip={downloadLabel}
size="sm"
color="primary"
coloringStyle="text"
onClick={onDownload}
>
<Download/>
</IconButton>
)}
</div>
)
}
109 changes: 109 additions & 0 deletions src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { HTMLAttributes, ReactNode } from 'react'
import clsx from 'clsx'
import { Camera, Paperclip, SendHorizontal } from 'lucide-react'
import { IconButton } from '../user-interaction/IconButton'
import { useControlledState } from '@/src/hooks/useControlledState'

export type ChatComposerProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> & {
value?: string,
initialValue?: string,
onValueChange?: (value: string) => void,
onSend: (value: string) => void,
placeholder?: string,
sendLabel?: string,
disabled?: boolean,
onCamera?: () => void,
cameraLabel?: string,
onAttachment?: () => void,
attachmentLabel?: string,
leading?: ReactNode,
trailing?: ReactNode,
}

export const ChatComposer = ({
value: controlledValue,
initialValue,
onValueChange,
onSend,
placeholder,
sendLabel = 'Send',
disabled = false,
onCamera,
cameraLabel = 'Camera',
onAttachment,
attachmentLabel = 'Attach file',
leading,
trailing,
...props
}: ChatComposerProps) => {
const [value, setValue] = useControlledState({
value: controlledValue,
onValueChange,
defaultValue: initialValue ?? '',
})

const send = () => {
const trimmed = (value ?? '').trim()
if (!trimmed) {
return
}
onSend(trimmed)
setValue('')
}

return (
<div {...props} className={clsx('chat-composer', props.className)}>
{leading}
{onCamera && (
<IconButton
tooltip={cameraLabel}
size="sm"
color="neutral"
coloringStyle="text"
disabled={disabled}
onClick={onCamera}
>
<Camera/>
</IconButton>
)}
{onAttachment && (
<IconButton
tooltip={attachmentLabel}
size="sm"
color="neutral"
coloringStyle="text"
disabled={disabled}
onClick={onAttachment}
>
<Paperclip/>
</IconButton>
)}
<div className="chat-composer-input-container">
<input
className="chat-composer-input"
value={value ?? ''}
placeholder={placeholder}
disabled={disabled}
onChange={event => setValue(event.target.value)}
onKeyDown={event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
send()
}
}}
/>
{trailing}
</div>
<IconButton
tooltip={sendLabel}
color="primary"
coloringStyle="solid"
disabled={disabled || !(value ?? '').trim()}
onClick={send}
className="chat-composer-send-button"
>
<SendHorizontal/>
</IconButton>
</div>
)
}
Loading
Loading