From ede363af2b8f8a3523334f71864511cbe3ab0b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Sat, 30 Nov 2024 23:29:35 +0100 Subject: [PATCH] Adds back the ability to suggest gifts for each participant --- src/components/ParticipantRow.tsx | 2 +- src/components/ParticipantsList.tsx | 9 +++-- src/components/ParticipantsTextView.tsx | 4 +- src/components/RulesModal.tsx | 15 +++++++ src/components/SecretSantaLinks.tsx | 11 +++-- src/i18n/en.ts | 5 ++- src/i18n/fr.ts | 5 ++- src/pages/Pairing.tsx | 30 +++++++++----- src/types.ts | 7 ++++ src/utils/generatePairs.ts | 2 +- src/utils/links.ts | 13 +++++- src/utils/parseParticipants.ts | 53 ++++++++++++++++++++----- 12 files changed, 122 insertions(+), 34 deletions(-) diff --git a/src/components/ParticipantRow.tsx b/src/components/ParticipantRow.tsx index 82b5731..311342a 100644 --- a/src/components/ParticipantRow.tsx +++ b/src/components/ParticipantRow.tsx @@ -38,7 +38,7 @@ export function ParticipantRow({
- {adjustedPairings.map(([giver, receiver]) => ( + {adjustedPairings.map(([giver, receiver, hint]) => ( {giver}: 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')} diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 5d6f41a..9646f59 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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", @@ -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", diff --git a/src/i18n/fr.ts b/src/i18n/fr.ts index 9ca79f4..ac40593 100644 --- a/src/i18n/fr.ts +++ b/src/i18n/fr.ts @@ -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", @@ -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", diff --git a/src/pages/Pairing.tsx b/src/pages/Pairing.tsx index c818980..6e8d26c 100644 --- a/src/pages/Pairing.tsx +++ b/src/pages/Pairing.tsx @@ -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`); @@ -36,13 +45,13 @@ export function Pairing() { const [searchParams] = useSearchParams(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [receiver, setReceiver] = useState<[string, string] | null>(null); + const [assignment, setAssignment] = useState<[string, ReceiverData] | null>(null); const [instructions, setInstructions] = useState(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); @@ -72,7 +81,7 @@ export function Pairing() { return (
- {!loading && ( + {!loading && assignment && ( {receiver![0]} + name: {assignment![0]} }} />

- {receiver![1]} + {assignment[1].name}
{instructions && (
-
- {instructions} +
+ {assignment[1].hint ?

{assignment[1].hint}

: null} +

{instructions}

)} diff --git a/src/types.ts b/src/types.ts index 159fee8..a8f7ae1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,7 +6,14 @@ export interface Rule { export interface Participant { id: string; name: string; + hint?: string; rules: Rule[]; } export type Participants = Record; + +// New type for encrypted data +export interface ReceiverData { + name: string; + hint?: string; +} diff --git a/src/utils/generatePairs.ts b/src/utils/generatePairs.ts index 91250c3..4dace44 100644 --- a/src/utils/generatePairs.ts +++ b/src/utils/generatePairs.ts @@ -14,7 +14,7 @@ export function checkRules(rules: Rule[]): string | null { } export function generateGenerationHash(participants: Record): 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 = { diff --git a/src/utils/links.ts b/src/utils/links.ts index 7a53982..fe11e15 100644 --- a/src/utils/links.ts +++ b/src/utils/links.ts @@ -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()}`; } diff --git a/src/utils/parseParticipants.ts b/src/utils/parseParticipants.ts index db2b1d8..8048546 100644 --- a/src/utils/parseParticipants.ts +++ b/src/utils/parseParticipants.ts @@ -25,11 +25,18 @@ export function formatParticipantText(participants: Record) .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): ParseResult { const lines = input.split('\n').map(line => line.trim()); const result: Record = {}; const nameToId: Record = {}; @@ -37,6 +44,7 @@ export function parseParticipantsText(input: string): ParseResult { const parsedLines: { line: number, name: string, + hint?: string, extra: string[], }[] = []; @@ -44,20 +52,47 @@ export function parseParticipantsText(input: string): ParseResult { 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, @@ -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 @@ -112,4 +147,4 @@ export function parseParticipantsText(input: string): ParseResult { } return { ok: true, participants: result }; -} \ No newline at end of file +}