feat(rchoices): inline CrowdSurf swipe cards with sortition
Replace CrowdSurf tab placeholder with working swipe-card interface populated from rChoices session data (or demo fallback). Uses seeded PRNG (mulberry32 + djb2 hash) for deterministic daily sortition per user, preventing position bias. Right-swipe = approve (casts vote via local-first client), left-swipe = skip. Swipe state persists in localStorage across page reloads. Includes summary view with session-grouped approvals and reset functionality. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1811f5e7b4
commit
383441edf7
|
|
@ -9,6 +9,16 @@ import { TourEngine } from "../../../shared/tour-engine";
|
||||||
import { ChoicesLocalFirstClient } from "../local-first-client";
|
import { ChoicesLocalFirstClient } from "../local-first-client";
|
||||||
import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas";
|
import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas";
|
||||||
|
|
||||||
|
// ── CrowdSurf types ──
|
||||||
|
interface CrowdSurfOption {
|
||||||
|
optionId: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
sessionId: string;
|
||||||
|
sessionTitle: string;
|
||||||
|
sessionType: 'vote' | 'rank' | 'score';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Auth helpers ──
|
// ── Auth helpers ──
|
||||||
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
|
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
|
||||||
try {
|
try {
|
||||||
|
|
@ -47,6 +57,16 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
private activeSessionId: string | null = null;
|
private activeSessionId: string | null = null;
|
||||||
private sessionVotes: Map<string, ChoiceVote[]> = new Map();
|
private sessionVotes: Map<string, ChoiceVote[]> = new Map();
|
||||||
|
|
||||||
|
/* CrowdSurf inline state */
|
||||||
|
private csOptions: CrowdSurfOption[] = [];
|
||||||
|
private csCurrentIndex = 0;
|
||||||
|
private csSwipedMap: Map<string, 'right' | 'left'> = new Map();
|
||||||
|
private csIsDragging = false;
|
||||||
|
private csStartX = 0;
|
||||||
|
private csCurrentX = 0;
|
||||||
|
private csIsAnimating = false;
|
||||||
|
private _csTransitionTimer: number | null = null;
|
||||||
|
|
||||||
// Guided tour
|
// Guided tour
|
||||||
private _tour!: TourEngine;
|
private _tour!: TourEngine;
|
||||||
private static readonly TOUR_STEPS = [
|
private static readonly TOUR_STEPS = [
|
||||||
|
|
@ -83,6 +103,10 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
clearInterval(this.simTimer);
|
clearInterval(this.simTimer);
|
||||||
this.simTimer = null;
|
this.simTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this._csTransitionTimer !== null) {
|
||||||
|
clearTimeout(this._csTransitionTimer);
|
||||||
|
this._csTransitionTimer = null;
|
||||||
|
}
|
||||||
this._lfcUnsub?.();
|
this._lfcUnsub?.();
|
||||||
this._lfcUnsub = null;
|
this._lfcUnsub = null;
|
||||||
this.lfClient?.disconnect();
|
this.lfClient?.disconnect();
|
||||||
|
|
@ -573,6 +597,30 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
.vote-reset:hover { border-color: var(--rs-error); color: #fca5a5; }
|
.vote-reset:hover { border-color: var(--rs-error); color: #fca5a5; }
|
||||||
.vote-status { text-align: center; margin-bottom: 1rem; font-size: 0.8rem; color: var(--rs-text-muted); }
|
.vote-status { text-align: center; margin-bottom: 1rem; font-size: 0.8rem; color: var(--rs-text-muted); }
|
||||||
|
|
||||||
|
/* CrowdSurf inline */
|
||||||
|
.cs-inline { max-width: 420px; margin-inline: auto; }
|
||||||
|
.cs-progress-header { display: flex; justify-content: space-between; margin-bottom: 0.75rem; }
|
||||||
|
.cs-card-stack { display: flex; flex-direction: column; align-items: center; min-height: 240px; justify-content: center; }
|
||||||
|
.cs-card { position: relative; width: 100%; background: linear-gradient(135deg, var(--rs-bg-surface) 0%, var(--rs-bg-surface-raised, var(--rs-bg-surface)) 100%); border: 1px solid var(--rs-border); border-radius: 16px; padding: 1.5rem; cursor: grab; user-select: none; touch-action: pan-y; }
|
||||||
|
.cs-card:active { cursor: grabbing; }
|
||||||
|
.cs-card-body { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.cs-type-badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; width: fit-content; }
|
||||||
|
.cs-card-session { font-size: 0.85rem; color: var(--rs-text-secondary); }
|
||||||
|
.cs-card-option { font-size: 1.3rem; font-weight: 700; color: var(--rs-text-primary); display: flex; align-items: center; gap: 10px; }
|
||||||
|
.cs-color-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.cs-swipe-indicator { position: absolute; top: 50%; transform: translateY(-50%); font-size: 1.1rem; font-weight: 700; padding: 6px 14px; border-radius: 8px; opacity: 0; transition: opacity 0.15s; pointer-events: none; z-index: 2; }
|
||||||
|
.cs-swipe-left { left: 12px; color: #ef4444; background: rgba(239,68,68,0.15); }
|
||||||
|
.cs-swipe-right { right: 12px; color: #22c55e; background: rgba(34,197,94,0.15); }
|
||||||
|
.cs-swipe-indicator.show { opacity: 1; }
|
||||||
|
.cs-swipe-buttons { display: flex; justify-content: center; gap: 2rem; margin-top: 1.25rem; }
|
||||||
|
.cs-btn-skip, .cs-btn-approve { width: 52px; height: 52px; border-radius: 50%; border: 2px solid; font-size: 1.3rem; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; background: var(--rs-bg-surface); font-family: inherit; }
|
||||||
|
.cs-btn-skip { border-color: #ef4444; color: #ef4444; }
|
||||||
|
.cs-btn-skip:hover { background: rgba(239,68,68,0.15); }
|
||||||
|
.cs-btn-approve { border-color: #22c55e; color: #22c55e; }
|
||||||
|
.cs-btn-approve:hover { background: rgba(34,197,94,0.15); }
|
||||||
|
.cs-btn-reset { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 0.85rem; font-family: inherit; transition: all 0.15s; margin-top: 0.75rem; }
|
||||||
|
.cs-btn-reset:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.grid { grid-template-columns: 1fr; }
|
.grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
@ -588,6 +636,9 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
.rank-name { font-size: 0.875rem; }
|
.rank-name { font-size: 0.875rem; }
|
||||||
.vote-option { padding: 0.625rem 0.75rem; }
|
.vote-option { padding: 0.625rem 0.75rem; }
|
||||||
.spider-svg { max-width: 300px; }
|
.spider-svg { max-width: 300px; }
|
||||||
|
.cs-card { padding: 1.25rem; border-radius: 12px; }
|
||||||
|
.cs-card-option { font-size: 1.1rem; }
|
||||||
|
.cs-btn-skip, .cs-btn-approve { width: 46px; height: 46px; font-size: 1.1rem; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
@ -731,17 +782,324 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- CrowdSurf helpers -- */
|
||||||
|
|
||||||
|
private static mulberry32(seed: number): () => number {
|
||||||
|
let s = seed | 0;
|
||||||
|
return () => {
|
||||||
|
s = (s + 0x6D2B79F5) | 0;
|
||||||
|
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static hashString(str: string): number {
|
||||||
|
let hash = 5381;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCrowdSurfOptions() {
|
||||||
|
const myDid = getMyDid() || 'anon';
|
||||||
|
const lsKey = `cs_swiped:${this.space}:${myDid}`;
|
||||||
|
|
||||||
|
// Restore persisted swipes
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(lsKey);
|
||||||
|
if (saved) {
|
||||||
|
const entries: [string, 'right' | 'left'][] = JSON.parse(saved);
|
||||||
|
this.csSwipedMap = new Map(entries);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
// Build pool from live sessions or demo data
|
||||||
|
let pool: CrowdSurfOption[] = [];
|
||||||
|
|
||||||
|
// Try live sessions first
|
||||||
|
const openSessions = this.sessions.filter(s => !s.closed);
|
||||||
|
for (const session of openSessions) {
|
||||||
|
const sType: 'vote' | 'rank' | 'score' = session.type === 'rank' ? 'rank' : session.type === 'score' ? 'score' : 'vote';
|
||||||
|
for (const opt of session.options) {
|
||||||
|
pool.push({
|
||||||
|
optionId: opt.id,
|
||||||
|
label: opt.label,
|
||||||
|
color: opt.color,
|
||||||
|
sessionId: session.id,
|
||||||
|
sessionTitle: session.title,
|
||||||
|
sessionType: sType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo mode fallback
|
||||||
|
if (this.space === 'demo' && pool.length === 0) {
|
||||||
|
for (const opt of this.voteOptions) {
|
||||||
|
pool.push({
|
||||||
|
optionId: opt.id,
|
||||||
|
label: opt.name,
|
||||||
|
color: opt.color,
|
||||||
|
sessionId: 'demo-vote',
|
||||||
|
sessionTitle: 'Movie Night',
|
||||||
|
sessionType: 'vote',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const item of this.rankItems) {
|
||||||
|
pool.push({
|
||||||
|
optionId: String(item.id),
|
||||||
|
label: item.name,
|
||||||
|
color: '#f59e0b',
|
||||||
|
sessionId: 'demo-rank',
|
||||||
|
sessionTitle: 'Lunch Spot',
|
||||||
|
sessionType: 'rank',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter already swiped
|
||||||
|
pool = pool.filter(o => !this.csSwipedMap.has(`${o.sessionId}:${o.optionId}`));
|
||||||
|
|
||||||
|
// Seeded shuffle (Fisher-Yates)
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const seed = FolkChoicesDashboard.hashString(`${myDid}:${this.space}:${today}`);
|
||||||
|
const rng = FolkChoicesDashboard.mulberry32(seed);
|
||||||
|
for (let i = pool.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(rng() * (i + 1));
|
||||||
|
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.csOptions = pool.slice(0, Math.min(10, pool.length));
|
||||||
|
this.csCurrentIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
private renderCrowdSurf(): string {
|
private renderCrowdSurf(): string {
|
||||||
return `<div style="text-align:center;padding:2rem 1rem;">
|
// Build options if empty or all consumed
|
||||||
<div style="font-size:3rem;margin-bottom:0.75rem;">🏄</div>
|
if (this.csOptions.length === 0 || this.csCurrentIndex >= this.csOptions.length) {
|
||||||
<h3 style="color:var(--rs-text-primary);margin:0 0 0.5rem;">CrowdSurf</h3>
|
this.buildCrowdSurfOptions();
|
||||||
<p style="color:var(--rs-text-secondary);margin:0 0 1.5rem;max-width:360px;margin-inline:auto;">
|
}
|
||||||
Swipe-based community coordination. Propose activities, set commitment thresholds, and watch them trigger when enough people join.
|
|
||||||
</p>
|
// No options at all
|
||||||
<a href="/${this.space}/crowdsurf" style="display:inline-block;padding:0.6rem 1.5rem;background:var(--rs-primary);color:#fff;border-radius:8px;text-decoration:none;font-weight:600;font-size:0.9rem;">Open CrowdSurf</a>
|
if (this.csOptions.length === 0) {
|
||||||
|
return `<div class="cs-inline">
|
||||||
|
<div style="text-align:center;padding:2rem 0;">
|
||||||
|
<div style="font-size:2.5rem;margin-bottom:0.75rem;">🏄</div>
|
||||||
|
<p style="color:var(--rs-text-secondary);margin:0 0 1rem;">No open polls to surf yet.</p>
|
||||||
|
<p style="color:var(--rs-text-muted);font-size:0.8rem;margin:0 0 1rem;">Create a poll in the Voting tab, then come back to swipe!</p>
|
||||||
|
${this.csSwipedMap.size > 0 ? `<button class="cs-btn-reset" data-cs-action="reset">Start Over</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// All swiped — show summary
|
||||||
|
if (this.csCurrentIndex >= this.csOptions.length) {
|
||||||
|
return this.renderCrowdSurfSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active card
|
||||||
|
const opt = this.csOptions[this.csCurrentIndex];
|
||||||
|
const approved = Array.from(this.csSwipedMap.values()).filter(v => v === 'right').length;
|
||||||
|
const typeBadgeColors: Record<string, string> = { vote: '#3b82f6', rank: '#f59e0b', score: '#10b981' };
|
||||||
|
const badgeColor = typeBadgeColors[opt.sessionType] || '#3b82f6';
|
||||||
|
|
||||||
|
return `<div class="cs-inline">
|
||||||
|
<div class="cs-progress-header">
|
||||||
|
<span style="color:var(--rs-text-muted);font-size:0.8rem;">${this.csCurrentIndex + 1} of ${this.csOptions.length}</span>
|
||||||
|
<span style="color:var(--rs-text-muted);font-size:0.8rem;">${approved} approved</span>
|
||||||
|
</div>
|
||||||
|
<div class="cs-card-stack">
|
||||||
|
<div class="cs-card" id="cs-inline-card">
|
||||||
|
<div class="cs-swipe-indicator cs-swipe-left">✗ Skip</div>
|
||||||
|
<div class="cs-swipe-indicator cs-swipe-right">✓ Approve</div>
|
||||||
|
<div class="cs-card-body">
|
||||||
|
<div class="cs-type-badge" style="background:${badgeColor}20;color:${badgeColor}">${opt.sessionType}</div>
|
||||||
|
<div class="cs-card-session">${this.esc(opt.sessionTitle)}</div>
|
||||||
|
<div class="cs-card-option">
|
||||||
|
<span class="cs-color-dot" style="background:${opt.color}"></span>
|
||||||
|
${this.esc(opt.label)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cs-swipe-buttons">
|
||||||
|
<button class="cs-btn-skip" data-cs-action="skip" title="Skip">✗</button>
|
||||||
|
<button class="cs-btn-approve" data-cs-action="approve" title="Approve">✓</button>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private renderCrowdSurfSummary(): string {
|
||||||
|
const approved: CrowdSurfOption[] = [];
|
||||||
|
this.csSwipedMap.forEach((dir, key) => {
|
||||||
|
if (dir !== 'right') return;
|
||||||
|
const [sessionId, optionId] = key.split(':');
|
||||||
|
const opt = this.csOptions.find(o => o.sessionId === sessionId && o.optionId === optionId);
|
||||||
|
if (opt) approved.push(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group by session
|
||||||
|
const grouped = new Map<string, CrowdSurfOption[]>();
|
||||||
|
for (const opt of approved) {
|
||||||
|
const list = grouped.get(opt.sessionId) || [];
|
||||||
|
list.push(opt);
|
||||||
|
grouped.set(opt.sessionId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
let groupHtml = '';
|
||||||
|
grouped.forEach((opts) => {
|
||||||
|
const title = opts[0].sessionTitle;
|
||||||
|
const items = opts.map(o =>
|
||||||
|
`<div style="display:flex;align-items:center;gap:6px;padding:4px 0;">
|
||||||
|
<span class="cs-color-dot" style="background:${o.color}"></span>
|
||||||
|
<span style="color:var(--rs-text-primary);font-size:0.9rem;">${this.esc(o.label)}</span>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
groupHtml += `<div style="margin-bottom:1rem;">
|
||||||
|
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--rs-text-muted);margin-bottom:4px;">${this.esc(title)}</div>
|
||||||
|
${items}
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `<div class="cs-inline">
|
||||||
|
<div style="text-align:center;padding:1.5rem 0;">
|
||||||
|
<div style="font-size:2rem;margin-bottom:0.5rem;">✓</div>
|
||||||
|
<h3 style="color:var(--rs-text-primary);margin:0 0 0.5rem;font-size:1.1rem;">All done!</h3>
|
||||||
|
<p style="color:var(--rs-text-secondary);margin:0 0 1.25rem;font-size:0.85rem;">
|
||||||
|
You approved ${approved.length} of ${this.csSwipedMap.size} options
|
||||||
|
</p>
|
||||||
|
${groupHtml || `<p style="color:var(--rs-text-muted);font-size:0.85rem;">No approvals this round.</p>`}
|
||||||
|
<button class="cs-btn-reset" data-cs-action="reset">Start Over</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupCrowdSurfSwipe() {
|
||||||
|
const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null;
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
// Clear any pending transition timer from previous card
|
||||||
|
if (this._csTransitionTimer !== null) {
|
||||||
|
clearTimeout(this._csTransitionTimer);
|
||||||
|
this._csTransitionTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStart = (clientX: number) => {
|
||||||
|
if (this.csIsAnimating) return;
|
||||||
|
// Clear any lingering transition
|
||||||
|
if (this._csTransitionTimer !== null) {
|
||||||
|
clearTimeout(this._csTransitionTimer);
|
||||||
|
this._csTransitionTimer = null;
|
||||||
|
}
|
||||||
|
card.style.transition = '';
|
||||||
|
this.csStartX = clientX;
|
||||||
|
this.csCurrentX = clientX;
|
||||||
|
this.csIsDragging = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMove = (clientX: number) => {
|
||||||
|
if (!this.csIsDragging || this.csIsAnimating) return;
|
||||||
|
this.csCurrentX = clientX;
|
||||||
|
const diffX = this.csCurrentX - this.csStartX;
|
||||||
|
const rotation = diffX * 0.1;
|
||||||
|
card.style.transform = `translateX(${diffX}px) rotate(${rotation}deg)`;
|
||||||
|
|
||||||
|
const leftInd = card.querySelector('.cs-swipe-left') as HTMLElement;
|
||||||
|
const rightInd = card.querySelector('.cs-swipe-right') as HTMLElement;
|
||||||
|
|
||||||
|
if (diffX < -50) {
|
||||||
|
leftInd?.classList.add('show');
|
||||||
|
rightInd?.classList.remove('show');
|
||||||
|
} else if (diffX > 50) {
|
||||||
|
rightInd?.classList.add('show');
|
||||||
|
leftInd?.classList.remove('show');
|
||||||
|
} else {
|
||||||
|
leftInd?.classList.remove('show');
|
||||||
|
rightInd?.classList.remove('show');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
if (!this.csIsDragging || this.csIsAnimating) return;
|
||||||
|
this.csIsDragging = false;
|
||||||
|
const diffX = this.csCurrentX - this.csStartX;
|
||||||
|
|
||||||
|
card.querySelector('.cs-swipe-left')?.classList.remove('show');
|
||||||
|
card.querySelector('.cs-swipe-right')?.classList.remove('show');
|
||||||
|
|
||||||
|
if (Math.abs(diffX) > 100) {
|
||||||
|
const direction = diffX > 0 ? 1 : -1;
|
||||||
|
this.csIsAnimating = true;
|
||||||
|
card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out';
|
||||||
|
card.style.transform = `translateX(${direction * 500}px) rotate(${direction * 30}deg)`;
|
||||||
|
card.style.opacity = '0';
|
||||||
|
this._csTransitionTimer = window.setTimeout(() => {
|
||||||
|
card.style.transform = '';
|
||||||
|
card.style.opacity = '';
|
||||||
|
card.style.transition = '';
|
||||||
|
this._csTransitionTimer = null;
|
||||||
|
this.handleCrowdSurfSwipe(diffX > 0 ? 'right' : 'left');
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
card.style.transition = 'transform 0.2s ease-out';
|
||||||
|
card.style.transform = '';
|
||||||
|
this._csTransitionTimer = window.setTimeout(() => {
|
||||||
|
card.style.transition = '';
|
||||||
|
this._csTransitionTimer = null;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
card.addEventListener('pointerdown', (e: PointerEvent) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
card.setPointerCapture(e.pointerId);
|
||||||
|
card.style.touchAction = 'none';
|
||||||
|
handleStart(e.clientX);
|
||||||
|
});
|
||||||
|
card.addEventListener('pointermove', (e: PointerEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMove(e.clientX);
|
||||||
|
});
|
||||||
|
card.addEventListener('pointerup', () => handleEnd());
|
||||||
|
card.addEventListener('pointercancel', () => {
|
||||||
|
this.csIsDragging = false;
|
||||||
|
card.style.transform = '';
|
||||||
|
card.style.transition = '';
|
||||||
|
card.style.opacity = '';
|
||||||
|
card.style.touchAction = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleCrowdSurfSwipe(direction: 'right' | 'left') {
|
||||||
|
this.csIsAnimating = false;
|
||||||
|
if (this.csCurrentIndex >= this.csOptions.length) return;
|
||||||
|
|
||||||
|
const opt = this.csOptions[this.csCurrentIndex];
|
||||||
|
const swipeKey = `${opt.sessionId}:${opt.optionId}`;
|
||||||
|
this.csSwipedMap.set(swipeKey, direction);
|
||||||
|
|
||||||
|
// Persist to localStorage
|
||||||
|
const myDid = getMyDid() || 'anon';
|
||||||
|
const lsKey = `cs_swiped:${this.space}:${myDid}`;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(lsKey, JSON.stringify(Array.from(this.csSwipedMap.entries())));
|
||||||
|
} catch { /* quota */ }
|
||||||
|
|
||||||
|
// Cast vote on right swipe (live mode)
|
||||||
|
if (direction === 'right' && this.lfClient && opt.sessionId !== 'demo-vote' && opt.sessionId !== 'demo-rank') {
|
||||||
|
const did = getMyDid();
|
||||||
|
if (did) {
|
||||||
|
const existing = this.lfClient.getMyVote(opt.sessionId, did);
|
||||||
|
const newChoices = { ...(existing?.choices || {}), [opt.optionId]: 1 };
|
||||||
|
this.lfClient.castVote(opt.sessionId, did, newChoices);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.csCurrentIndex++;
|
||||||
|
this.renderDemo();
|
||||||
|
this.bindDemoEvents();
|
||||||
|
}
|
||||||
|
|
||||||
/* -- Demo event binding -- */
|
/* -- Demo event binding -- */
|
||||||
|
|
||||||
private bindDemoEvents() {
|
private bindDemoEvents() {
|
||||||
|
|
@ -861,6 +1219,45 @@ class FolkChoicesDashboard extends HTMLElement {
|
||||||
this.renderDemo();
|
this.renderDemo();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CrowdSurf swipe + buttons
|
||||||
|
this.setupCrowdSurfSwipe();
|
||||||
|
this.shadow.querySelector('[data-cs-action="skip"]')?.addEventListener('click', () => {
|
||||||
|
if (this.csIsAnimating) return;
|
||||||
|
const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null;
|
||||||
|
if (card) {
|
||||||
|
this.csIsAnimating = true;
|
||||||
|
card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out';
|
||||||
|
card.style.transform = 'translateX(-500px) rotate(-30deg)';
|
||||||
|
card.style.opacity = '0';
|
||||||
|
setTimeout(() => this.handleCrowdSurfSwipe('left'), 300);
|
||||||
|
} else {
|
||||||
|
this.handleCrowdSurfSwipe('left');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.shadow.querySelector('[data-cs-action="approve"]')?.addEventListener('click', () => {
|
||||||
|
if (this.csIsAnimating) return;
|
||||||
|
const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null;
|
||||||
|
if (card) {
|
||||||
|
this.csIsAnimating = true;
|
||||||
|
card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out';
|
||||||
|
card.style.transform = 'translateX(500px) rotate(30deg)';
|
||||||
|
card.style.opacity = '0';
|
||||||
|
setTimeout(() => this.handleCrowdSurfSwipe('right'), 300);
|
||||||
|
} else {
|
||||||
|
this.handleCrowdSurfSwipe('right');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.shadow.querySelector('[data-cs-action="reset"]')?.addEventListener('click', () => {
|
||||||
|
const myDid = getMyDid() || 'anon';
|
||||||
|
const lsKey = `cs_swiped:${this.space}:${myDid}`;
|
||||||
|
localStorage.removeItem(lsKey);
|
||||||
|
this.csSwipedMap.clear();
|
||||||
|
this.csOptions = [];
|
||||||
|
this.csCurrentIndex = 0;
|
||||||
|
this.renderDemo();
|
||||||
|
this.bindDemoEvents();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private esc(s: string): string {
|
private esc(s: string): string {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue