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
@@ -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<OpportunitiesProps> = ({
|
||||
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');
|
||||
|
||||
@@ -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<OpportunityModalProps> = ({
|
||||
await opportunitiesApi.create(payload);
|
||||
addToast('Opportunity angelegt.', 'success');
|
||||
}
|
||||
if (form.stage === 'Zusage' && opportunity?.stage !== 'Zusage') fireConfetti();
|
||||
onSaved();
|
||||
onClose();
|
||||
} 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