Skip to content

Commit

Permalink
Adds back the ability to suggest gifts for each participant
Browse files Browse the repository at this point in the history
  • Loading branch information
arcanis committed Nov 30, 2024
1 parent 129c2dd commit ede363a
Show file tree
Hide file tree
Showing 12 changed files with 122 additions and 34 deletions.
2 changes: 1 addition & 1 deletion src/components/ParticipantRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function ParticipantRow({
<button
onClick={onOpenRules}
className={`px-2 sm:px-3 py-2 rounded hover:opacity-80 flex-shrink-0 ${
participant.rules.length > 0
participant.rules.length > 0 || participant.hint
? 'bg-yellow-500 text-white'
: 'bg-gray-200 text-gray-600'
}`}
Expand Down
9 changes: 6 additions & 3 deletions src/components/ParticipantsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export function ParticipantsList({
}: ParticipantsListProps) {
const { t } = useTranslation();
const [nextParticipantId, setNextParticipantId] = useState(() => crypto.randomUUID());
const [isTextView, setIsTextView] = useState(false);

const updateParticipant = (id: string, name: string) => {
if (id === nextParticipantId) {
Expand Down Expand Up @@ -53,8 +52,12 @@ export function ParticipantsList({
}];

return (
<div className="space-y-2">
<div className="space-y-2 pr-2">
<div className="space-y-4">
<p className="mt-1 text-xs text-gray-500">
{t('participants.generationWarning')}
</p>

<div className="space-y-2">
{participantsList.map((participant, index) => (
<ParticipantRow
key={participant.id}
Expand Down
4 changes: 2 additions & 2 deletions src/components/ParticipantsTextView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Participant } from '../types';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { parseParticipantsText, ParseError, formatParticipantText } from '../utils/parseParticipants';
import { ArrowsClockwise } from '@phosphor-icons/react';
import { useTranslation } from 'react-i18next';
Expand All @@ -19,7 +19,7 @@ export function ParticipantsTextView({ participants, onChangeParticipants, onGen
const handleChange = (newText: string) => {
setText(newText);

const result = parseParticipantsText(newText);
const result = parseParticipantsText(newText, participants);
if (result.ok) {
setError(null);
onChangeParticipants(result.participants);
Expand Down
15 changes: 15 additions & 0 deletions src/components/RulesModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function RulesModal({
const { t } = useTranslation();
const participant = participants[participantId];
const [localRules, setLocalRules] = useState<Rule[]>(participant.rules);
const [localHint, setLocalHint] = useState<string>(participant.hint || '');

// Add escape key handler
useEffect(() => {
Expand Down Expand Up @@ -56,6 +57,7 @@ export function RulesModal({
const handleSave = () => {
onChangeParticipants(produce(participants, draft => {
draft[participantId].rules = localRules;
draft[participantId].hint = localHint || undefined;
}));
onClose();
};
Expand All @@ -73,6 +75,19 @@ export function RulesModal({
{t('rules.title', { name: participant.name })}
</h2>

<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
{t('rules.hintLabel')}
</label>
<input
type="text"
value={localHint}
onChange={(e) => setLocalHint(e.target.value)}
placeholder={t('rules.hintPlaceholder')}
className="w-full p-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>

<div className="space-y-4 mb-6">
{localRules.map((rule, index) => (
<div key={index} className="flex gap-2 items-center">
Expand Down
11 changes: 7 additions & 4 deletions src/components/SecretSantaLinks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,20 @@ export function SecretSantaLinks({ assignments, instructions, participants, onGe
const currentHash = generateGenerationHash(participants);
const hasChanged = currentHash !== assignments.hash;

const adjustedPairings = assignments.pairings.map(({giver, receiver}): [string, string] => [
const adjustedPairings = assignments.pairings.map(({giver, receiver}): [string, string, string | undefined] => [
participants[giver.id]?.name ?? giver.name,
participants[receiver.id]?.name ?? receiver.name,
participants[receiver.id]?.hint,
]);

console.log(assignments.pairings, participants)

adjustedPairings.sort((a, b) => {
return a[0].localeCompare(b[0]);
});

const handleExportCSV = () => {
const csvContent = generateCSV(adjustedPairings);
const csvContent = generateCSV(adjustedPairings.map(([giver, receiver]) => [giver, receiver]));
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);

Expand Down Expand Up @@ -69,13 +72,13 @@ export function SecretSantaLinks({ assignments, instructions, participants, onGe
</button>
</div>
<div className="grid grid-cols-[minmax(100px,auto)_1fr] gap-3">
{adjustedPairings.map(([giver, receiver]) => (
{adjustedPairings.map(([giver, receiver, hint]) => (
<React.Fragment key={giver}>
<span className="font-medium self-center">
{giver}:
</span>
<CopyButton
textToCopy={() => generateAssignmentLink(giver, receiver, instructions)}
textToCopy={() => generateAssignmentLink(giver, receiver, hint, instructions)}
className="p-2 bg-blue-500 text-white rounded hover:bg-blue-600 flex items-center justify-center gap-2"
>
{t('links.copySecretLink')}
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const en = {
},
participants: {
title: "Participants",
generationWarning: "Important: Any change made to the participant list or settings will require creating new pairings. Existing links won't be retroactively modified.",
addPerson: "Add Person",
generatePairs: "Generate Pairings",
enterName: "Enter participant name",
Expand All @@ -53,7 +54,9 @@ export const en = {
addMustRule: "Force a Pairing",
addMustNotRule: "Prevent a Pairing",
cancel: "Cancel",
saveRules: "Save Rules"
saveRules: "Save Rules",
hintLabel: 'Gift Hint',
hintPlaceholder: 'Enter a hint about gift preferences (optional)',
},
links: {
title: "Links to Share",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const fr: Translations = {
},
participants: {
title: "Participants",
generationWarning: "Important: Toute modification de la liste des participants ou de leurs paramètres nécessitera la création de nouvelles associations. Les liens existants ne seront pas modifiés.",
addPerson: "Ajouter une Personne",
generatePairs: "Générer les Associations",
enterName: "Entrez le nom du participant",
Expand All @@ -55,7 +56,9 @@ export const fr: Translations = {
addMustRule: "Forcer une association",
addMustNotRule: "Exclure une association",
cancel: "Annuler",
saveRules: "Enregistrer les Règles"
saveRules: "Enregistrer les Règles",
hintLabel: 'Suggestions pour le cadeau',
hintPlaceholder: 'Entrez une indication sur le type de cadeau adapté à ce participant (optionnel)',
},
links: {
title: "Liens à Partager",
Expand Down
30 changes: 20 additions & 10 deletions src/pages/Pairing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,31 @@ import { ArrowLeft, Info } from '@phosphor-icons/react';
import { motion } from 'framer-motion';
import CryptoJS from 'crypto-js';
import { Layout } from "../components/Layout";
import { ReceiverData } from "../types";

async function loadPairing(searchParams: URLSearchParams): Promise<[string, string]> {
async function loadPairing(searchParams: URLSearchParams): Promise<[string, ReceiverData]> {
// Legacy pairings, not generated anymore; remove after 2025-01-01
if (searchParams.has(`name`) && searchParams.has(`key`) && searchParams.has(`pairing`)) {
const name = searchParams.get(`name`)!;
const key = searchParams.get(`key`)!;
const pairing = searchParams.get(`pairing`)!;

return [name, CryptoJS.AES.decrypt(pairing, key).toString(CryptoJS.enc.Utf8)];
return [name, {name: CryptoJS.AES.decrypt(pairing, key).toString(CryptoJS.enc.Utf8)}];
}

if (searchParams.has(`to`)) {
const from = searchParams.get('from')!;
const to = searchParams.get('to')!;
const decrypted = await decryptText(to);

return [from, await decryptText(to)];
try {
// Try to parse as JSON first (new format with hint)
const data = JSON.parse(decrypted) as ReceiverData;
return [from, data];
} catch {
// If parsing fails, it's the old format (just the name)
return [from, {name: decrypted, hint: undefined} as ReceiverData];
}
}

throw new Error(`Missing key or to parameter in search params`);
Expand All @@ -36,13 +45,13 @@ export function Pairing() {
const [searchParams] = useSearchParams();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [receiver, setReceiver] = useState<[string, string] | null>(null);
const [assignment, setAssignment] = useState<[string, ReceiverData] | null>(null);
const [instructions, setInstructions] = useState<string | null>(null);

useEffect(() => {
const decryptReceiver = async () => {
try {
setReceiver(await loadPairing(searchParams));
setAssignment(await loadPairing(searchParams));
setInstructions(searchParams.get('info'));
} catch (err) {
console.error('Decryption error:', err);
Expand Down Expand Up @@ -72,7 +81,7 @@ export function Pairing() {
return (
<Layout menuItems={menuItems}>
<div>
{!loading && (
{!loading && assignment && (
<motion.div
initial={{ rotateZ: -360 * 1, scale: 0 }}
animate={{ rotateZ: 0, scale: 1, opacity: 1 }}
Expand All @@ -87,20 +96,21 @@ export function Pairing() {
<Trans
i18nKey="pairing.assignment"
components={{
name: <span className="font-semibold">{receiver![0]}</span>
name: <span className="font-semibold">{assignment![0]}</span>
}}
/>
</p>
<div className="text-8xl font-bold text-center p-6 font-dancing-script">
{receiver![1]}
{assignment[1].name}
</div>
{instructions && (
<div className="mt-6 flex p-4 bg-gray-50 rounded-lg leading-6 text-gray-600 whitespace-pre-wrap">
<div className="mr-4">
<Info size={24}/>
</div>
<div>
{instructions}
<div className="space-y-2">
{assignment[1].hint ? <p>{assignment[1].hint}</p> : null}
<p>{instructions}</p>
</div>
</div>
)}
Expand Down
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ export interface Rule {
export interface Participant {
id: string;
name: string;
hint?: string;
rules: Rule[];
}

export type Participants = Record<string, Participant>;

// New type for encrypted data
export interface ReceiverData {
name: string;
hint?: string;
}
2 changes: 1 addition & 1 deletion src/utils/generatePairs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function checkRules(rules: Rule[]): string | null {
}

export function generateGenerationHash(participants: Record<string, Participant>): string {
return JSON.stringify(Object.values(participants).map(p => ({rules: p.rules})));
return JSON.stringify(Object.values(participants).map(p => ({rules: p.rules, hint: p.hint})));
}

export type GeneratedPairs = {
Expand Down
13 changes: 11 additions & 2 deletions src/utils/links.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { encryptText } from "./crypto";
import { ReceiverData } from "../types";

export async function generateAssignmentLink(giver: string, receiver: string, instructions?: string) {
export async function generateAssignmentLink(giver: string, receiver: string, receiverHint?: string, instructions?: string) {
const baseUrl = `${window.location.origin}${window.location.pathname.replace(/\/[^/]*$/, '')}`;
const encryptedReceiver = await encryptText(receiver);

// If there's a hint, encrypt a JSON object
const dataToEncrypt = receiverHint
? JSON.stringify({ name: receiver, hint: receiverHint } as ReceiverData)
: receiver;

const encryptedReceiver = await encryptText(dataToEncrypt);
const params = new URLSearchParams({
from: giver,
to: encryptedReceiver,
});

if (instructions?.trim()) {
params.set('info', instructions.trim());
}

return `${baseUrl}/pairing?${params.toString()}`;
}

Expand Down
53 changes: 44 additions & 9 deletions src/utils/parseParticipants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,39 +25,74 @@ export function formatParticipantText(participants: Record<string, Participant>)
.filter(r => r.type === 'mustNot')
.map(r => `!${participants[r.targetParticipantId]?.name ?? ''}`);

return `${[participant.name, ...mustRules, ...mustNotRules].join(' ')}\n`;
const hintPart = participant.hint
? [`(${participant.hint})`]
: [];

return `${[participant.name, ...hintPart, ...mustRules, ...mustNotRules].join(' ')}\n`;
}).join('');
}

export function parseParticipantsText(input: string): ParseResult {
const PAREN_1 = /[!=(]/g;
const PAREN_2 = /[()]/g;

export function parseParticipantsText(input: string, existingParticipants?: Record<string, Participant>): ParseResult {
const lines = input.split('\n').map(line => line.trim());
const result: Record<string, Participant> = {};
const nameToId: Record<string, string> = {};

const parsedLines: {
line: number,
name: string,
hint?: string,
extra: string[],
}[] = [];

for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line === '') continue;

const parts = line
let splitIndex = PAREN_1.exec(line)?.index;

const name = typeof splitIndex === 'number'
? line.slice(0, splitIndex).trim()
: line.trim();

let hint: string | undefined;
if (typeof splitIndex === 'number' && line[splitIndex] === '(') {
let depth = 1;
let j = splitIndex + 1;

while (depth > 0 && j < line.length) {
if (line[j] === '(') depth++;
if (line[j] === ')') depth--;
j++;
}

hint = line.slice(splitIndex + 1, j - 1);
splitIndex = j;
}

const remainingPart = line.slice(splitIndex);
const parts = remainingPart
.trim()
.split(/([!=])/)
.map(part => part.trim());

const [name, ...extra] = parts;
if (!name) {
return { ok: false, line: i + 1, key: 'errors.emptyName' };
}

parsedLines.push({ line: i + 1, name, extra });
parsedLines.push({
line: i + 1,
name: name.trim(),
hint: hint?.trim(),
extra: parts.filter(Boolean)
});
}

// First pass: create participants and build name-to-id mapping
for (const {line, name} of parsedLines) {
for (const {line, name, hint} of parsedLines) {
if (nameToId[name]) {
return {
ok: false,
Expand All @@ -67,9 +102,9 @@ export function parseParticipantsText(input: string): ParseResult {
};
}

const id = crypto.randomUUID();
const id = existingParticipants?.[name]?.id ?? crypto.randomUUID();
nameToId[name] = id;
result[id] = { id, name, rules: [] };
result[id] = { id, name, hint, rules: [] };
}

// Second pass: process rules
Expand Down Expand Up @@ -112,4 +147,4 @@ export function parseParticipantsText(input: string): ParseResult {
}

return { ok: true, participants: result };
}
}

0 comments on commit ede363a

Please sign in to comment.