From 59b962d66b6ef143331f1f0ce6f0e0d3aa3ca433 Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Wed, 23 Sep 2026 13:08:57 +0200 Subject: [PATCH] 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 --- docs/07-anleitung-benutzer.md | 4 + docs/09-quellcode.md | 1 + .../opportunities/Opportunities.tsx | 2 + .../opportunities/OpportunityModal.tsx | 2 + src/lib/confetti.ts | 107 ++++++++++++++++++ 5 files changed, 116 insertions(+) create mode 100644 src/lib/confetti.ts diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 1f3330a..d08da13 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -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 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 > „Neu" gesetzt**. Der alte Trichter (Qualifizierung, Angebot, Verhandlung, > Gewonnen, Verloren) ließ nicht erkennen, in welcher der neuen Phasen ein diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index 9eeca4f..6ab699e 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -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/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` | | `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) | ### `src/types.ts` diff --git a/src/components/opportunities/Opportunities.tsx b/src/components/opportunities/Opportunities.tsx index 81565fe..1095951 100644 --- a/src/components/opportunities/Opportunities.tsx +++ b/src/components/opportunities/Opportunities.tsx @@ -14,6 +14,7 @@ import { STAGE_LABELS } from './opportunityUi'; import { ConfirmDialog } from '../ui/Modal'; import { useToast } from '../Toast'; import { opportunitiesApi } from '../../lib/api'; +import { fireConfetti } from '../../lib/confetti'; import { OPEN_STAGES, OPPORTUNITY_STAGES, @@ -217,6 +218,7 @@ export const Opportunities: React.FC = ({ try { await opportunitiesApi.update(opportunity.id, { stage }); addToast(`„${opportunity.title}" ist jetzt in Phase ${STAGE_LABELS[stage]}.`, 'success'); + if (stage === 'Zusage') fireConfetti(); reload(); } catch (err) { addToast(err instanceof Error ? err.message : 'Phase konnte nicht geändert werden.', 'error'); diff --git a/src/components/opportunities/OpportunityModal.tsx b/src/components/opportunities/OpportunityModal.tsx index b3e27a6..4a379d7 100644 --- a/src/components/opportunities/OpportunityModal.tsx +++ b/src/components/opportunities/OpportunityModal.tsx @@ -7,6 +7,7 @@ import { STAGE_COLORS, STAGE_HINTS, STAGE_LABELS } from './opportunityUi'; import { useToast } from '../Toast'; import { acquisitionOptionsApi, opportunitiesApi } from '../../lib/api'; import { useAuth } from '../../lib/auth'; +import { fireConfetti } from '../../lib/confetti'; import { OPPORTUNITY_STAGES, contactFullName, @@ -244,6 +245,7 @@ export const OpportunityModal: React.FC = ({ await opportunitiesApi.create(payload); addToast('Opportunity angelegt.', 'success'); } + if (form.stage === 'Zusage' && opportunity?.stage !== 'Zusage') fireConfetti(); onSaved(); onClose(); } catch (err) { diff --git a/src/lib/confetti.ts b/src/lib/confetti.ts new file mode 100644 index 0000000..3832de7 --- /dev/null +++ b/src/lib/confetti.ts @@ -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); +}