feat(opportunities): Konfetti beim Wechsel auf Zusage

Kleines Canvas-Konfetti ohne Abhaengigkeit (src/lib/confetti.ts), ausgeloest
beim Phasenwechsel in der Pipeline und beim Speichern im Dialog. Entfaellt bei
prefers-reduced-motion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-23 13:08:57 +02:00
co-authored by Claude Opus 5.5
parent e35c7518db
commit 59b962d66b
5 changed files with 116 additions and 0 deletions
+4
View File
@@ -459,6 +459,10 @@ bestätigt, aber noch nicht beauftragt. *Beauftragt* und *Absage* sind das Ende.
Der Filter *Nur offene* nutzt genau diese Grenze, ebenso die Pipeline-Summe in Der Filter *Nur offene* nutzt genau diese Grenze, ebenso die Pipeline-Summe in
der Kopfzeile. der Kopfzeile.
Landet eine Opportunity auf *Zusage* - per Ziehen in der Pipeline, über das
Phasenmenü der Karte oder beim Speichern im Dialog -, gibt es kurz Konfetti.
Wer im Betriebssystem „Bewegung reduzieren" eingestellt hat, sieht keins.
> Bei der Umstellung am 18.08.2026 wurden **alle vorhandenen Opportunities auf > Bei der Umstellung am 18.08.2026 wurden **alle vorhandenen Opportunities auf
> „Neu" gesetzt**. Der alte Trichter (Qualifizierung, Angebot, Verhandlung, > „Neu" gesetzt**. Der alte Trichter (Qualifizierung, Angebot, Verhandlung,
> Gewonnen, Verloren) ließ nicht erkennen, in welcher der neuen Phasen ein > Gewonnen, Verloren) ließ nicht erkennen, in welcher der neuen Phasen ein
+1
View File
@@ -425,6 +425,7 @@ Warum genau diese Konstanten festgelegt sind, steht in
| `components/ui/Modal.tsx` | `Modal` und `ConfirmDialog`; `useDismiss()` schließt per Escape und per Klick in den Hintergrund. Escape trifft nur den obersten Dialog (Stapel `openModals`), der Hintergrundklick muss dort beginnen **und** enden. `ConfirmDialog` kennt zwei Tonlagen: `tone="danger"` (Standard, rot - es geht etwas verloren) und `tone="neutral"` (grün, bloße Rückfrage, z. B. beim Wiederherstellen) | | `components/ui/Modal.tsx` | `Modal` und `ConfirmDialog`; `useDismiss()` schließt per Escape und per Klick in den Hintergrund. Escape trifft nur den obersten Dialog (Stapel `openModals`), der Hintergrundklick muss dort beginnen **und** enden. `ConfirmDialog` kennt zwei Tonlagen: `tone="danger"` (Standard, rot - es geht etwas verloren) und `tone="neutral"` (grün, bloße Rückfrage, z. B. beim Wiederherstellen) |
| `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` | | `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` |
| `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat | | `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat |
| `lib/confetti.ts` | `fireConfetti()`: Canvas-Konfetti ohne Abhängigkeit, räumt sich nach 4,5 s selbst weg, entfällt bei `prefers-reduced-motion`. Aufgerufen beim Phasenwechsel auf *Zusage* (`Opportunities.changeStage`, `OpportunityModal.handleSubmit`) |
| `lib/imageCompress.ts` | `compressImage(datei)` auf 256 px, WebP → JPEG → PNG; `compressImageSource(dataUrl)` geht denselben Weg für ein Bild, das nicht aus einer Datei kommt (LinkedIn-Foto) | | `lib/imageCompress.ts` | `compressImage(datei)` auf 256 px, WebP → JPEG → PNG; `compressImageSource(dataUrl)` geht denselben Weg für ein Bild, das nicht aus einer Datei kommt (LinkedIn-Foto) |
### `src/types.ts` ### `src/types.ts`
@@ -14,6 +14,7 @@ import { STAGE_LABELS } from './opportunityUi';
import { ConfirmDialog } from '../ui/Modal'; import { ConfirmDialog } from '../ui/Modal';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { opportunitiesApi } from '../../lib/api'; import { opportunitiesApi } from '../../lib/api';
import { fireConfetti } from '../../lib/confetti';
import { import {
OPEN_STAGES, OPEN_STAGES,
OPPORTUNITY_STAGES, OPPORTUNITY_STAGES,
@@ -217,6 +218,7 @@ export const Opportunities: React.FC<OpportunitiesProps> = ({
try { try {
await opportunitiesApi.update(opportunity.id, { stage }); await opportunitiesApi.update(opportunity.id, { stage });
addToast(`„${opportunity.title}" ist jetzt in Phase ${STAGE_LABELS[stage]}.`, 'success'); addToast(`„${opportunity.title}" ist jetzt in Phase ${STAGE_LABELS[stage]}.`, 'success');
if (stage === 'Zusage') fireConfetti();
reload(); reload();
} catch (err) { } catch (err) {
addToast(err instanceof Error ? err.message : 'Phase konnte nicht geändert werden.', 'error'); addToast(err instanceof Error ? err.message : 'Phase konnte nicht geändert werden.', 'error');
@@ -7,6 +7,7 @@ import { STAGE_COLORS, STAGE_HINTS, STAGE_LABELS } from './opportunityUi';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { acquisitionOptionsApi, opportunitiesApi } from '../../lib/api'; import { acquisitionOptionsApi, opportunitiesApi } from '../../lib/api';
import { useAuth } from '../../lib/auth'; import { useAuth } from '../../lib/auth';
import { fireConfetti } from '../../lib/confetti';
import { import {
OPPORTUNITY_STAGES, OPPORTUNITY_STAGES,
contactFullName, contactFullName,
@@ -244,6 +245,7 @@ export const OpportunityModal: React.FC<OpportunityModalProps> = ({
await opportunitiesApi.create(payload); await opportunitiesApi.create(payload);
addToast('Opportunity angelegt.', 'success'); addToast('Opportunity angelegt.', 'success');
} }
if (form.stage === 'Zusage' && opportunity?.stage !== 'Zusage') fireConfetti();
onSaved(); onSaved();
onClose(); onClose();
} catch (err) { } catch (err) {
+107
View File
@@ -0,0 +1,107 @@
/*
* Kleines Konfetti ohne Abhaengigkeit: ein Canvas ueber der ganzen Seite,
* zwei Schuesse von unten links und rechts, danach raeumt es sich selbst weg.
* Wer im System "Bewegung reduzieren" eingestellt hat, bekommt nichts.
*/
const COLORS = ['#7c3aed', '#a78bfa', '#f59e0b', '#10b981', '#3b82f6', '#ef4444', '#ec4899'];
const PIECES_PER_SIDE = 120;
// Physik je 60-Hz-Frame; bei anderer Bildrate wird ueber dt skaliert.
const GRAVITY = 0.12;
const DRAG = 0.95;
const DURATION_MS = 4500;
interface Piece {
x: number;
y: number;
vx: number;
vy: number;
size: number;
color: string;
angle: number;
spin: number;
wobble: number;
}
export function fireConfetti(): void {
if (typeof window === 'undefined') return;
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
const canvas = document.createElement('canvas');
const ratio = window.devicePixelRatio || 1;
const width = window.innerWidth;
const height = window.innerHeight;
canvas.width = width * ratio;
canvas.height = height * ratio;
canvas.style.cssText =
'position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:9999';
canvas.setAttribute('aria-hidden', 'true');
document.body.appendChild(canvas);
const ctx = canvas.getContext('2d');
if (!ctx) {
canvas.remove();
return;
}
ctx.scale(ratio, ratio);
const pieces: Piece[] = [];
for (const side of [-1, 1]) {
const originX = side < 0 ? 0 : width;
for (let i = 0; i < PIECES_PER_SIDE; i++) {
// Schraeg nach oben in Richtung Bildmitte.
const angle = (side < 0 ? -60 : -120) * (Math.PI / 180) + (Math.random() - 0.5) * 0.9;
const speed = 22 + Math.random() * 22;
pieces.push({
x: originX,
y: height,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: 6 + Math.random() * 6,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
angle: Math.random() * Math.PI * 2,
spin: (Math.random() - 0.5) * 0.3,
wobble: Math.random() * Math.PI * 2,
});
}
}
const start = performance.now();
let last = start;
const tick = (now: number) => {
const elapsed = now - start;
// Nach einem Tab-Wechsel nicht in einem Sprung durchrechnen.
const dt = Math.min(3, (now - last) / (1000 / 60));
last = now;
const drag = Math.pow(DRAG, dt);
ctx.clearRect(0, 0, width, height);
// Letztes Drittel ausblenden statt hart abschneiden.
ctx.globalAlpha = Math.max(0, Math.min(1, (DURATION_MS - elapsed) / (DURATION_MS / 3)));
for (const piece of pieces) {
piece.vx *= drag;
piece.vy = piece.vy * drag + GRAVITY * dt;
piece.wobble += 0.1 * dt;
piece.x += (piece.vx + Math.sin(piece.wobble)) * dt;
piece.y += piece.vy * dt;
piece.angle += piece.spin * dt;
ctx.save();
ctx.translate(piece.x, piece.y);
ctx.rotate(piece.angle);
// Flattern: die Breite pendelt, das Stueck wirkt gedreht.
ctx.scale(Math.cos(piece.wobble), 1);
ctx.fillStyle = piece.color;
ctx.fillRect(-piece.size / 2, -piece.size / 4, piece.size, piece.size / 2);
ctx.restore();
}
if (elapsed < DURATION_MS) {
requestAnimationFrame(tick);
} else {
canvas.remove();
}
};
requestAnimationFrame(tick);
}