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:
co-authored by
Claude Opus 5.5
parent
e35c7518db
commit
59b962d66b
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user