diff --git a/AGENTS.md b/AGENTS.md index e56d1f4..61231e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,15 @@ Provide concise guardrails so every contribution keeps the SvelteKit app stable, - Name `.svelte` files in kebab-case even when the exported component stays PascalCase. - Keep UI primitives self-contained; only split supporting files when there is a real reuse boundary. +#### Directory Responsibilities + +- `src/routes/` – Feature entry points; keep files thin and delegate logic/UI to `@/components` and `@/lib`. +- `src/components/ui/` – Source of truth for shadcn primitives defined in `components.json`; extend via props/variants instead of recreating components elsewhere. +- `src/components//` – Feature-scoped compositions that stitch primitives together; avoid business logic here. +- `src/lib/` – Shared domain logic, state helpers, schemas, and utilities usable across routes; never import UI from here. +- `src/lib/server/` – Server-only helpers (DB, integrations). Client bundles must not import from this tree. +- `src/lib/server/db/` – Drizzle schema + migrations helpers; any schema change must pair with `db:generate` and `db:migrate`. + ### Svelte & Reactivity - Use Svelte 5 runes (`$props`, `$state`, `$derived`, `$effect`, `$bindable`). diff --git a/memory/plans/2026-02-03-team-feedback-toasts.md b/memory/plans/2026-02-03-team-feedback-toasts.md new file mode 100644 index 0000000..672263b --- /dev/null +++ b/memory/plans/2026-02-03-team-feedback-toasts.md @@ -0,0 +1,76 @@ +# Plan: team-feedback-toasts + +Requirement: `memory/requirements/2026-02-03-team-feedback-toasts-requirement.md` +Status: READY_TO_EXECUTE +Owner: Planner + +> **How to use**: Author tasks so they remain stack-agnostic. Mark placeholders like `{FRAMEWORK_CMD}` or `{API_ENDPOINT}` where the project must inject specifics. + +## Pre-flight Checklist + +- Confirm requirement assumptions resolved? TODO:CONFIRM_FLOW_LIST, TODO:COPY_TONE, and TODO:TOAST_THEMING remain open; default to descriptive copy and top-right desktop / bottom-center mobile positioning unless stakeholders redirect. +- Repo/branch prepared? (`git status`, dependencies installed) +- Skills required: [ui-system-principles] +- Tooling commands verified or tagged as needed (`npm run check`, `npm run lint`) + +## Tasks + +### Task 1: Mount global Sonner toaster + +- **Goal**: Install `svelte-sonner` and add a single `` in `src/routes/+layout.svelte` so every page can surface toast feedback that honors shadcn tokens and TODO:TOAST_THEMING (top-right desktop, bottom-center mobile, `closeButton`, duration defaults). +- **Files/Areas**: `package.json`, `package-lock.json`, `src/routes/+layout.svelte`, `src/app.d.ts` (if module augmentation is required), `{SVELTEKIT_APP}` theme tokens consumed by Toaster class overrides. +- **Verify**: `npm run check` +- **Dependencies**: None +- **Rollback**: Remove the `svelte-sonner` dependency and delete the Toaster import/render block from the layout. +- **Notes**: Ensure Toaster inherits existing CSS variables so it matches shadcn visual language; verify it persists across navigations per requirement. + +### Task 2: Provide toast helper utilities + +- **Goal**: Create `src/lib/ui/toast.ts` (or equivalent) exporting `toastSuccess`, `toastError`, and `toastPromise` wrappers with descriptive copy defaults (per TODO:COPY_TONE) and shared icon/duration rules. +- **Files/Areas**: `src/lib/ui/toast.ts`, `src/lib/ui/index.ts` (if you re-export), `{TEAM_API_ROUTES}` call sites documentation for helper usage examples. +- **Verify**: `npm run lint` +- **Dependencies**: Task 1 +- **Rollback**: Delete the helper module and remove any new exports/imports referencing it. +- **Notes**: Centralize copy tokens/constants for reuse; document TODO:CONFIRM_FLOW_LIST items so missing flows can be added later without diverging patterns. + +### Task 3: Instrument dashboard route actions + +- **Goal**: Wrap all team/variant mutations in `src/routes/+page.svelte` with the helper functions so create/delete collection, rename, duplicate, and destructive confirmation flows surface toast success/error without duplicates. +- **Files/Areas**: `src/routes/+page.svelte`, related form actions under `src/routes/+page.server.ts` if they emit status, `@/lib/stores/team-replace-context` if route-level stores need toast awareness. +- **Verify**: `{QA_SCENARIO}` covering dashboard CRUD triggers Sonner toasts + `npm run check` +- **Dependencies**: Tasks 1-2 +- **Rollback**: Revert toast helper calls in the dashboard route to restore previous behavior. +- **Notes**: Prefer `toastPromise` for long-running fetches, ensure copy references the correct entity names, and avoid duplicate toasts by short-circuiting on race conditions. + +### Task 4: Add builder-level toast coverage + +- **Goal**: Update `src/components/team/team-builder.svelte` (and related builder helpers) to emit toast feedback for duplicate, rename, Pokemon add/remove/update, and all `/api/teams/...` fetch results currently silent. +- **Files/Areas**: `src/components/team/team-builder.svelte`, `@/lib/stores/team-replace-context.ts`, builder-specific helpers under `src/lib/teams/*`. +- **Verify**: `{QA_SCENARIO}` exercising duplicate/rename/add/remove in the builder + `npm run check` +- **Dependencies**: Tasks 1-3 +- **Rollback**: Remove the injected helper calls from builder files. +- **Notes**: Ensure promise toasts resolve before navigation (`goto`) executes to keep UX consistent; respect TODO:TOAST_THEMING for different viewports. + +### Task 5: Cover Pokemon slot components + +- **Goal**: Extend `{TEAM_POKEMON_COMPONENTS}` such as `PokemonDetails` and `PokemonSearch` to emit contextual success/error toasts for slot updates, stat edits, and failed fetch operations instead of silent console logs. +- **Files/Areas**: `src/components/team/pokemon-details.svelte`, `src/components/team/pokemon-search.svelte`, any shared Pokemon CRUD helpers in `src/lib/teams/*`. +- **Verify**: `{QA_SCENARIO}` running Pokemon slot CRUD in desktop + mobile widths (ensuring bottom-center positioning on narrow viewports) +- **Dependencies**: Tasks 1-4 +- **Rollback**: Remove toast helper imports/calls from Pokemon components. +- **Notes**: Keep helper copy descriptive per TODO:COPY_TONE; dedupe toasts when multiple slots update simultaneously to mitigate the rapid-fire risk noted in the requirement. + +### Task 6: Final QA and regression sweep + +- **Goal**: Validate that all async flows emit exactly one toast per success/failure, Sonner theming matches shadcn tokens, and no lint/type regressions were introduced. +- **Files/Areas**: Whole repo (focus on mutated routes/components), test plans in `{QA_SCENARIO}` docs. +- **Verify**: `npm run check`, `npm run lint`, and manual `{QA_SCENARIO}` walkthrough on desktop + mobile widths ensuring Toaster persists across navigation. +- **Dependencies**: Tasks 1-5 +- **Rollback**: If regressions appear, revert the specific helper usage or re-run Task 5 → Task 3 changes selectively. +- **Notes**: Capture any remaining TODO resolution needs (e.g., copy/positioning decisions) and escalate if blockers persist. + +## Dependencies & Parallelism + +- Sequential: Task 1 → Task 2 → Task 3 → Task 4 → Task 5 → Task 6. +- Parallel groups: None (each step builds on Sonner + helper foundations). +- External blockers: Await decisions for TODO:CONFIRM_FLOW_LIST, TODO:COPY_TONE, TODO:TOAST_THEMING if stakeholders override the documented defaults. diff --git a/memory/requirements/2026-02-03-team-feedback-toasts-requirement.md b/memory/requirements/2026-02-03-team-feedback-toasts-requirement.md new file mode 100644 index 0000000..85403a3 --- /dev/null +++ b/memory/requirements/2026-02-03-team-feedback-toasts-requirement.md @@ -0,0 +1,70 @@ +# Requirement: team-feedback-toasts + +Date: 2026-02-03 +Status: READY_FOR_PLAN +Owner: Researcher + +> **How to use**: Keep placeholders (e.g., `{FRAMEWORK}`, `{API_NAME}`) so this document can be cloned for any repo. Replace them only when you specialize for a concrete project. + +## Problem + +- User Story: "As a team builder managing variants in `{SVELTEKIT_APP}`, I need immediate toast feedback whenever actions like duplicate, rename, delete, or Pokemon updates run so I know what succeeded or failed before navigation changes hide the UI state." +- Desired Outcome: Every async mutation in the team dashboard and builder surfaces a Sonner toast (success/error) that matches the shadcn visual language and acknowledges the action (e.g., "Variant duplicated"), especially flows that currently feel silent such as duplicate. +- Success Metric: `{QA_SCENARIO}` can trigger team/variant CRUD + Pokemon slot mutations and confirm `toast.*` fired for success/failure 100% of the time without duplicate toasts; UX review signs off once Sonner toasts render in layout across desktop/mobile. + +## Solution Overview + +- Key Idea: Install and use the shadcn-sanctioned `svelte-sonner` component so a single `` lives in `src/routes/+layout.svelte`, then wrap common toast helpers (success/error/promise) that downstream actions (`handleDuplicate`, `handleCreateCollection`, `handleUpdatePokemon`, etc.) call right after each fetch resolves or throws. +- System Boundary: Root layout for mounting Sonner, the dashboard route `src/routes/+page.svelte`, the variant builder `src/components/team/team-builder.svelte`, and any helper utilities that orchestrate Pokemon CRUD (`PokemonDetails`, `PokemonSearch`, `{TEAM_API_ROUTES}`). +- Assumptions to Validate: `svelte-sonner` styles can inherit existing CSS variables without extra overrides; promise-based toasts won't conflict with `goto` navigation; we can centralize copy strings in one helper so translations stay consistent. + +## Scope of Changes + +Files/modules to modify (use placeholders where needed): + +- `package.json` + lockfile — Add `svelte-sonner` per https://www.shadcn-svelte.com/docs/components/sonner and wire any Vite typings if needed. +- `src/routes/+layout.svelte` — Import `{ Toaster }` from `svelte-sonner`, render it once (top-right, `closeButton`, themed via existing tokens) so all pages can push toasts. +- `src/lib/ui/toast.ts` (or similar) — Export thin helpers around `toast` (`toastSuccess`, `toastError`, `toastPromise`) with opinionated defaults (icon, duration, class) aligned with shadcn tokens. +- `src/routes/+page.svelte` — Wrap team/variant CRUD fetches in `toast.promise` or explicit success/error notifications (create/delete collection, create variant, rename, destructive confirmations) so the dashboard reflects background work. +- `src/components/team/team-builder.svelte` — Surface toasts for duplicate, rename, Pokemon add/update/remove flows, and any API failures coming from `fetch('/api/teams/...')` handlers. +- `{TEAM_POKEMON_COMPONENTS}` (e.g., `PokemonDetails`, `PokemonSearch`) — Trigger contextual successes for slot updates or show failure toast when `fetch` rejects instead of silent console errors. + +New files/modules: + +- `src/lib/ui/toast.ts` — Centralize Sonner helper exports so route/components import a single source of truth for toast variants/copy. + +## Existing References + +- Similar pattern: There is no toast system yet, but `src/components/ui/dialog` + shadcn primitives demonstrate how shared UI elements live under `src/components/ui/` with Tailwind tokens; mirror that structure for Sonner helpers. +- Reusable utilities/components: `Button`, `Input`, `Dialog`, `TeamBuilder`, `PokemonDetails`, and the store in `@/lib/stores/team-replace-context` already manage async flows where toast calls should be inserted. +- External dependencies: New dependency on `svelte-sonner`; existing fetch endpoints under `/api/teams/*` provide success/error states to reflect. + +## Data Flow / Contracts + +- Inputs: User actions on dashboard/builder buttons (create/duplicate/delete teams or variants, edit names, replace Pokemon, remove Pokemon). +- Processing: Each handler already performs `fetch` calls to `/api/teams/...`; add toast helper calls that observe the promise, display loading states when appropriate, and handle thrown errors. +- Outputs: Toast UI rendered by `` acknowledges completion/failure; navigations triggered by `goto` should happen only after success to avoid flashing stale states; state stores update as they do now. +- Schema impacts: None—this work sits entirely in the UI layer; just ensure toast copy references existing entity names without requiring schema changes. + +## Compliance With `AGENTS.md` + +- Standards touched: Keep Svelte 5 runes, reuse shadcn primitives, ensure Sonner styling matches defined CSS variables, and rerun `npm run check` + `npm run lint` after wiring toasts. +- Deviations: None expected; Sonner is already a documented shadcn component, so no custom toast systems should be introduced. + +## Edge Cases & Risks + +- Rapid-fire actions (e.g., spamming duplicate) could queue multiple toasts; need rate-limiting or deduping copy to avoid noise. +- Navigations immediately after a toast might unmount components; ensure the global `` persists so the toast remains visible even when routes change. +- Network failures currently only log to console; forgetting to add `toast.error` leaves the UX unchanged—auditing every fetch path is critical. + +## Out of Scope + +- Rewriting API endpoints or adding optimistic updates beyond toast feedback. +- Designing a custom notification center or persistence for historical toasts; Sonner transient feedback is sufficient. +- Introducing non-shadcn notification libraries; requirement is to stick with Sonner. + +## Open Questions + +- TODO:CONFIRM_FLOW_LIST — Which exact flows beyond duplicate need toasts (e.g., Pokemon stat edits, Name edits, new variant navigation) before implementation? all those. +- TODO:COPY_TONE — Should success/error copy follow a playful tone ("Team duplicated!"), or stay strictly descriptive for enterprise feel? strictly descriptive +- TODO:TOAST_THEMING — Do we want global positioning/duration defaults (top-right, 4s) or scenario-specific overrides like sticky destructive toasts for deletions? ye, but for mobile shoud be bottom-center diff --git a/package-lock.json b/package-lock.json index c54074f..4b1113a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "clsx": "^2.1.1", "drizzle-orm": "^0.44.4", "nanoid": "^5.1.6", + "svelte-sonner": "^1.0.7", "tailwind-merge": "^3.3.1" }, "devDependencies": { @@ -6409,6 +6410,34 @@ } } }, + "node_modules/svelte-sonner": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/svelte-sonner/-/svelte-sonner-1.0.7.tgz", + "integrity": "sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A==", + "license": "MIT", + "dependencies": { + "runed": "^0.28.0" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, + "node_modules/svelte-sonner/node_modules/runed": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.28.0.tgz", + "integrity": "sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==", + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, "node_modules/svelte-toolbelt": { "version": "0.10.6", "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", diff --git a/package.json b/package.json index 1f1cbf1..f4362e7 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "clsx": "^2.1.1", "drizzle-orm": "^0.44.4", "nanoid": "^5.1.6", + "svelte-sonner": "^1.0.7", "tailwind-merge": "^3.3.1" }, "devDependencies": { diff --git a/src/components/team/pokemon-details.svelte b/src/components/team/pokemon-details.svelte index 2119b67..d73b57d 100644 --- a/src/components/team/pokemon-details.svelte +++ b/src/components/team/pokemon-details.svelte @@ -15,7 +15,8 @@ import TypeBadge from '@/components/ui/type-badge.svelte'; import { X, Ban, Lock, Trash2 } from '@lucide/svelte'; import { useTeamReplaceContext } from '@/lib/stores/team-replace-context'; - import type { Stat } from '@/lib/pokemon/stat'; + import { STAT_LABELS, type Stat } from '@/lib/pokemon/stat'; + import { toastSuccess } from '@/components/ui/toast'; let { pokemon, onClose, onRemove, onUpdate } = $props<{ pokemon: TeamPokemon; @@ -47,6 +48,8 @@ speed: 'spe' }; + const STAT_KEYS: Stat[] = ['hp', 'atk', 'def', 'spA', 'spD', 'spe']; + let fullData = $state(null); let activeTab = $state<'stats' | 'build' | 'moves'>('stats'); let loading = $state(true); @@ -60,9 +63,10 @@ loading = true; try { const res = await fetch(`/api/pokemon/${pokemon.pokemonId}`); - if (res.ok) { - fullData = await res.json(); + if (!res.ok) { + throw new Error('Failed to load Pokémon details'); } + fullData = (await res.json()) as PokemonData; } catch { fullData = null; } finally { @@ -114,6 +118,31 @@ spe: pokemon.ivSpe ?? 31 }); + const listStats = (stats: Stat[]) => stats.map((stat) => STAT_LABELS[stat]).join(', '); + + const describeStatChange = (type: 'ev' | 'iv', changed: Stat[], values: Record) => { + const label = type === 'ev' ? 'EVs' : 'IVs'; + if (changed.length === 1) { + const stat = changed[0]; + return `${STAT_LABELS[stat]} ${label} set to ${values[stat]}`; + } + if (changed.length === STAT_KEYS.length) { + return `${label} reset`; + } + return `${label} updated (${listStats(changed)})`; + }; + + const dispatchStatToast = (type: 'ev' | 'iv', changed: Stat[], values: Record) => { + if (!changed.length) return; + const slotLabel = pokemon.slot ? `Slot ${pokemon.slot}` : 'Current slot'; + toastSuccess({ + id: `${type}-spread-slot-${pokemon.slot}`, + title: type === 'ev' ? 'EV spread saved' : 'IV spread saved', + description: `${slotLabel}: ${describeStatChange(type, changed, values)}`, + duration: 3800 + }); + }; + const currentEvs = $derived({ hp: pokemon.evHp ?? 0, atk: pokemon.evAtk ?? 0, @@ -140,6 +169,7 @@ }; const handleEvsChange = (evs: Record) => { + const changedStats = STAT_KEYS.filter((stat) => currentEvs[stat] !== evs[stat]); onUpdate({ ...pokemon, evHp: evs.hp, @@ -149,9 +179,11 @@ evSpD: evs.spD, evSpe: evs.spe }); + dispatchStatToast('ev', changedStats, evs); }; const handleIvsChange = (ivs: Record) => { + const changedStats = STAT_KEYS.filter((stat) => currentIvs[stat] !== ivs[stat]); onUpdate({ ...pokemon, ivHp: ivs.hp, @@ -161,6 +193,7 @@ ivSpD: ivs.spD, ivSpe: ivs.spe }); + dispatchStatToast('iv', changedStats, ivs); }; const handleHeldItemChange = (item: { id: number; name: string; sprite: string } | null) => { diff --git a/src/components/team/pokemon-search.svelte b/src/components/team/pokemon-search.svelte index 2a99ef6..d94bfdd 100644 --- a/src/components/team/pokemon-search.svelte +++ b/src/components/team/pokemon-search.svelte @@ -17,6 +17,8 @@ PokemonSlotPreview, ReplacementCandidatePreview } from '@/components/team/types'; + import { toastError, toastSuccess } from '@/components/ui/toast'; + import { capitalize } from '@/lib/strings'; const { onReplace, @@ -73,6 +75,8 @@ isSearchOpen: false }); let hasPrefilledQuery = $state(false); + const humanizePokemonName = (value: string | null | undefined) => + value?.replace(/-/g, ' ') ?? 'Pokémon'; // Confirmation state for replacement flow let pendingConfirmation = $state<{ pokemon: PokemonData; form: PokemonForm | null } | null>(null); @@ -81,6 +85,7 @@ const replaceContext = useTeamReplaceContext(); const activeSlot = $derived(slotProp ?? replaceState.activeSlot); const pendingReplacement = $derived(pendingReplacementProp ?? replaceState.pendingReplacement); + const slotToastId = (slot: number) => `slot-selection-${slot}`; const primaryTypeColor = $derived( selectedPokemon @@ -116,6 +121,12 @@ const isCurrentResult = (result: PokemonSearchResult) => pendingReplacement?.pokemonId === result.id; + const getCandidateLabel = (pokemon: PokemonData, form: PokemonForm | null) => { + const baseName = humanizePokemonName(pokemon.name); + if (!form?.formName) return baseName; + return `${baseName} (${humanizePokemonName(form.formName)})`; + }; + $effect(() => { const unsubscribe = replaceContext.subscribe((value) => { replaceState = value; @@ -152,7 +163,10 @@ } } catch (error) { if ((error as Error).name !== 'AbortError') { - console.error('Search error:', error); + toastError({ + title: 'Search failed', + description: 'We could not load Pokémon search results. Please try again.' + }); } } loading = false; @@ -165,44 +179,53 @@ }); const handleResultClick = async (result: { name: string; id: number }) => { + const pokemonLabel = humanizePokemonName(result.name); try { const response = await fetch(`/api/pokemon/${result.id}`); - if (response.ok) { - const data = await response.json(); - selectedPokemon = data; - selectedForm = null; - forms = []; - - loadingForms = true; - try { - const baseName = result.name.split('-')[0]; - const formsResponse = await fetch(`/api/pokemon/${baseName}/forms`); - if (formsResponse.ok) { - const formsData = await formsResponse.json(); - if (formsData.length > 1) { - forms = formsData; - selectedForm = formsData.find((f: PokemonForm) => f.isDefault) || formsData[0]; - } - } - } catch (e) { - console.error('Error fetching forms:', e); + if (!response.ok) { + throw new Error('Failed to load Pokémon details'); + } + const data = (await response.json()) as PokemonData; + selectedPokemon = data; + selectedForm = null; + forms = []; + } catch { + return; + } + + loadingForms = true; + try { + const baseName = result.name.split('-')[0]; + const formsResponse = await fetch(`/api/pokemon/${baseName}/forms`); + if (formsResponse.ok) { + const formsData = (await formsResponse.json()) as PokemonForm[]; + if (formsData.length > 1) { + forms = formsData; + selectedForm = formsData.find((f: PokemonForm) => f.isDefault) || formsData[0]; } - loadingForms = false; } - } catch (error) { - console.error('Error fetching Pokemon:', error); + } catch { + toastError({ + title: 'Unable to load forms', + description: `We couldn't load alternate forms for ${pokemonLabel}.` + }); + } finally { + loadingForms = false; } }; const handleFormSelect = async (form: PokemonForm) => { + const previousForm = selectedForm; selectedForm = form; try { const response = await fetch(`/api/pokemon/${form.id}`); - if (response.ok) { - selectedPokemon = await response.json(); + if (!response.ok) { + throw new Error('Failed to load Pokémon form'); } - } catch (error) { - console.error('Error fetching Pokemon form:', error); + const data = (await response.json()) as PokemonData; + selectedPokemon = data; + } catch { + selectedForm = previousForm; } }; @@ -233,10 +256,24 @@ }; const executeReplacement = (pokemon: PokemonData, form: PokemonForm | null) => { - const payload = buildReplacementPayload(pokemon, form); - if (!payload) return; - onReplace?.(payload); - dispatch('replace', payload); + const replacementEvent = buildReplacementPayload(pokemon, form); + if (!replacementEvent) { + toastError({ + title: 'Choose a slot', + description: 'Select a slot before confirming a Pokémon.' + }); + return; + } + const candidateLabel = getCandidateLabel(pokemon, form); + const slot = replacementEvent.payload.slot; + onReplace?.(replacementEvent); + dispatch('replace', replacementEvent); + toastSuccess({ + id: slotToastId(slot), + title: `${capitalize(candidateLabel)} added`, + description: `Slot ${slot}`, + duration: 3600 + }); replaceContext.clear(); }; diff --git a/src/components/team/team-builder.svelte b/src/components/team/team-builder.svelte index ba49d88..9abdb50 100644 --- a/src/components/team/team-builder.svelte +++ b/src/components/team/team-builder.svelte @@ -11,6 +11,8 @@ } from '@/lib/stores/team-replace-context'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; + import { toastPromise } from '@/components/ui/toast'; + import { capitalize } from '@/lib/strings'; import { ChevronRight, Check, X, Pencil, Copy } from '@lucide/svelte'; import { goto, invalidateAll } from '$app/navigation'; import { resolve } from '$app/paths'; @@ -18,6 +20,9 @@ const TEAM_SLOTS = [1, 2, 3, 4, 5, 6]; + const humanizePokemonName = (value: string | null | undefined) => + value?.replace(/-/g, ' ') ?? 'Pokémon'; + const props = $props<{ teamName: string; variantId: string; @@ -105,17 +110,19 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); - if (response.ok) { - const saved: TeamPokemon = await response.json(); - pokemon = [...pokemon.filter((p) => p.slot !== saved.slot), saved]; - if (selectedPokemon?.slot === saved.slot) { - selectedPokemon = saved; - } + if (!response.ok) { + throw new Error('Failed to save Pokémon'); + } + const saved: TeamPokemon = await response.json(); + pokemon = [...pokemon.filter((p) => p.slot !== saved.slot), saved]; + if (selectedPokemon?.slot === saved.slot) { + selectedPokemon = saved; } } catch (error) { console.error('Error replacing Pokemon:', error); + } finally { + handleSearchClose(); } - handleSearchClose(); }; const handlePokemonReplace = (event: PokemonReplaceEventPayload) => { @@ -132,44 +139,85 @@ }; const handleRemovePokemon = async (slot: number) => { + const removedPokemon = pokemonBySlot.get(slot); + const removedName = humanizePokemonName(removedPokemon?.pokemonName); try { - await fetch(`/api/teams/${variantId}/pokemon`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ slot }) - }); + const removal = (async () => { + const response = await fetch(`/api/teams/${variantId}/pokemon`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slot }) + }); + if (!response.ok) { + throw new Error('Failed to remove Pokémon'); + } - pokemon = pokemon.filter((p) => p.slot !== slot); - selectedPokemon = null; - replaceContext.clear(); + pokemon = pokemon.filter((p) => p.slot !== slot); + selectedPokemon = null; + replaceContext.clear(); + })(); + toastPromise(removal, { + loading: `Removing ${capitalize(removedName)}...`, + success: `${capitalize(removedName)} removed`, + error: `Couldn't remove ${capitalize(removedName)}` + }); + await removal; } catch (error) { console.error('Error removing Pokemon:', error); } }; const handleUpdatePokemon = async (updated: TeamPokemon) => { + const updatedName = humanizePokemonName(updated.pokemonName); try { - await fetch(`/api/teams/${variantId}/pokemon`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updated) - }); + const updateRequest = (async () => { + const response = await fetch(`/api/teams/${variantId}/pokemon`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updated) + }); + if (!response.ok) { + throw new Error('Failed to update Pokémon'); + } - pokemon = pokemon.map((p) => (p.slot === updated.slot ? updated : p)); - selectedPokemon = updated; + pokemon = pokemon.map((p) => (p.slot === updated.slot ? updated : p)); + selectedPokemon = updated; + return updated; + })(); + toastPromise(updateRequest, { + loading: `Saving ${capitalize(updatedName)}...`, + success: `${capitalize(updatedName)} updated`, + error: `Couldn't update ${capitalize(updatedName)}` + }); + await updateRequest; } catch (error) { console.error('Error updating Pokemon:', error); } }; const handleNameSave = async () => { + const trimmedName = name.trim(); try { - await fetch(`/api/teams/${variantId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }) + const renameRequest = (async () => { + const response = await fetch(`/api/teams/${variantId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: trimmedName }) + }); + if (!response.ok) { + throw new Error('Failed to rename team'); + } + return trimmedName; + })(); + toastPromise(renameRequest, { + loading: 'Saving team name...', + success: 'Team name updated', + error: "Couldn't update the team name" }); + await renameRequest; + name = trimmedName; isEditingName = false; + nameInput = null; } catch (error) { console.error('Error updating team name:', error); } @@ -177,22 +225,33 @@ const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter') { - handleNameSave(); + void handleNameSave(); } else if (event.key === 'Escape') { isEditingName = false; name = getVariantName(); + nameInput = null; } }; const handleDuplicate = async () => { try { - const res = await fetch(`/api/teams/${variantId}/duplicate`, { - method: 'POST' + const duplicateRequest = (async (): Promise<{ id: string } | null> => { + const response = await fetch(`/api/teams/${variantId}/duplicate`, { + method: 'POST' + }); + if (!response.ok) { + throw new Error('Failed to duplicate team'); + } + return response.json(); + })(); + toastPromise(duplicateRequest, { + loading: 'Duplicating team...', + success: 'Team duplicated', + error: "Couldn't duplicate the team" }); - if (!res.ok) return; - const data = await res.json(); - if (data?.id) { - await goto(resolve(`/team/${data.id}`)); + const duplicatedTeam = await duplicateRequest; + if (duplicatedTeam?.id) { + await goto(resolve(`/team/${duplicatedTeam.id}`)); } } catch (error) { console.error('Error duplicating team:', error); @@ -272,6 +331,7 @@ onclick={() => { isEditingName = false; name = getVariantName(); + nameInput = null; }} title="Cancel" > diff --git a/src/components/ui/toast/index.ts b/src/components/ui/toast/index.ts new file mode 100644 index 0000000..e1fa9d5 --- /dev/null +++ b/src/components/ui/toast/index.ts @@ -0,0 +1,96 @@ +import { toast, type ExternalToast } from 'svelte-sonner'; +import { AlertTriangle, Check } from '@lucide/svelte'; +import type { Component } from 'svelte'; + +type ToastMessageOptions = ExternalToast & { + title?: string; + description?: string; +}; + +type ToastPromiseFactory = Promise | (() => Promise); + +type ToastRenderable = string | (() => string | Component); + +type ToastPromiseOptions = ExternalToast & { + loading?: ToastRenderable; + success?: string | ((data: Data) => string | Component); + error?: string | ((error: unknown) => string | Component); + finally?: () => void | Promise; +}; + +const DESCRIPTIVE_COPY = { + success: { + title: 'Request completed', + description: 'Your changes were saved successfully.' + }, + error: { + title: 'Request failed', + description: 'We could not finish this request. Please try again.' + }, + loading: 'Working on your request...' +} as const; + +const TOAST_DURATIONS = { + success: 2000, + error: 4000 +} as const; + +const TOAST_ICONS = { + success: Check, + error: AlertTriangle +} as const; + +export function toastSuccess(options: ToastMessageOptions = {}) { + const { + title = DESCRIPTIVE_COPY.success.title, + description = DESCRIPTIVE_COPY.success.description, + duration = TOAST_DURATIONS.success, + icon = TOAST_ICONS.success, + closeButton = true, + ...rest + } = options; + + return toast.success(title, { + ...rest, + closeButton, + description, + duration, + icon + }); +} + +export function toastError(options: ToastMessageOptions = {}) { + const { + title = DESCRIPTIVE_COPY.error.title, + description = DESCRIPTIVE_COPY.error.description, + duration = TOAST_DURATIONS.error, + icon = TOAST_ICONS.error, + closeButton = true, + ...rest + } = options; + + return toast.error(title, { + ...rest, + closeButton, + description, + duration, + icon + }); +} + +export function toastPromise( + promise: ToastPromiseFactory, + options: ToastPromiseOptions = {} +) { + const { loading, success, error, duration, closeButton, ...rest } = options; + + return toast.promise(promise, { + ...rest, + loading: loading ?? DESCRIPTIVE_COPY.loading, + success: success ?? DESCRIPTIVE_COPY.success.title, + error: error ?? DESCRIPTIVE_COPY.error.title, + duration: duration ?? TOAST_DURATIONS.success, + closeButton: closeButton ?? true, + richColors: true + }); +} diff --git a/src/components/ui/toast/toaster.svelte b/src/components/ui/toast/toaster.svelte new file mode 100644 index 0000000..ba217de --- /dev/null +++ b/src/components/ui/toast/toaster.svelte @@ -0,0 +1,11 @@ + + + + + diff --git a/src/lib/strings.ts b/src/lib/strings.ts new file mode 100644 index 0000000..f9084ad --- /dev/null +++ b/src/lib/strings.ts @@ -0,0 +1,5 @@ +export function capitalize(str: string): string { + if (!str) return str; + + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 33ca525..22134e7 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,6 +1,7 @@ @@ -22,3 +25,5 @@
{@render children?.()}
+ + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1f15042..4333f1f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3,6 +3,7 @@ import { resolve } from '$app/paths'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; + import { toastPromise } from '@/components/ui/toast'; import { Dialog, DialogContent, @@ -45,6 +46,8 @@ let pendingTeamId = $state(null); let isDeletingVariant = $state(false); let isDeletingTeam = $state(false); + let pendingVariantCreationTeamId = $state(null); + let savingTeamId = $state(null); const handleCreateCollection = async (event?: Event) => { event?.preventDefault(); @@ -55,13 +58,15 @@ return; } isCreatingCollection = true; - try { + const createRequest = (async () => { const res = await fetch('/api/teams', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'New Team' }) }); - if (!res.ok) return; + if (!res.ok) { + throw new Error('Failed to create collection'); + } const created = await res.json(); const createdTeam = { id: created.id, @@ -69,8 +74,17 @@ variants: [] }; teamsWithVariants = [createdTeam, ...teamsWithVariants]; + return createdTeam; + })(); + toastPromise(createRequest, { + loading: 'Creating collection...', + success: (createdTeam) => `Collection "${createdTeam?.name ?? 'New Team'}" created`, + error: 'Unable to create collection' + }); + try { + await createRequest; } catch (error) { - console.error('Error creating team:', error); + console.error('Error creating team collection:', error); } finally { isCreatingCollection = false; } @@ -97,50 +111,105 @@ const handleDeleteVariant = async () => { if (!pendingVariantId || isDeletingVariant) return; isDeletingVariant = true; - try { - const res = await fetch(`/api/teams/${pendingVariantId}`, { + const variantId = pendingVariantId; + const teamForVariant = teamsWithVariants.find((team) => + team.variants.some((variant) => variant.id === variantId) + ); + const variantForToast = teamForVariant?.variants.find((variant) => variant.id === variantId); + const variantLabel = variantForToast?.name ?? 'Variant'; + const deleteVariantRequest = (async () => { + const res = await fetch(`/api/teams/${variantId}`, { method: 'DELETE' }); - if (!res.ok) return; + if (!res.ok) { + throw new Error('Failed to delete variant'); + } teamsWithVariants = teamsWithVariants.map((team) => ({ ...team, - variants: team.variants.filter((variant) => variant.id !== pendingVariantId) + variants: team.variants.filter((variant) => variant.id !== variantId) })); + return variantLabel; + })(); + toastPromise(deleteVariantRequest, { + loading: `Deleting ${variantLabel}...`, + success: (label) => `Variant "${label ?? 'Variant'}" deleted`, + error: `Unable to delete ${variantLabel}` + }); + try { + await deleteVariantRequest; closeDeleteVariant(); } catch (error) { console.error('Error deleting variant:', error); - closeDeleteVariant(); + } finally { + isDeletingVariant = false; } }; const handleDeleteTeam = async () => { if (!pendingTeamId || isDeletingTeam) return; isDeletingTeam = true; - try { - const res = await fetch(`/api/teams/${pendingTeamId}/collection`, { + const teamId = pendingTeamId; + const teamForToast = teamsWithVariants.find((team) => team.id === teamId); + const teamLabel = teamForToast?.name ?? 'Collection'; + const deleteTeamRequest = (async () => { + const res = await fetch(`/api/teams/${teamId}/collection`, { method: 'DELETE' }); - if (!res.ok) return; - teamsWithVariants = teamsWithVariants.filter((team) => team.id !== pendingTeamId); + if (!res.ok) { + throw new Error('Failed to delete team'); + } + teamsWithVariants = teamsWithVariants.filter((team) => team.id !== teamId); + return teamLabel; + })(); + toastPromise(deleteTeamRequest, { + loading: `Deleting ${teamLabel}...`, + success: (label) => `Collection "${label ?? 'Collection'}" deleted`, + error: `Unable to delete ${teamLabel}` + }); + try { + await deleteTeamRequest; closeDeleteTeam(); } catch (error) { console.error('Error deleting team:', error); - closeDeleteTeam(); + } finally { + isDeletingTeam = false; } }; const handleCreateVariant = async (teamId: string) => { - try { + if (pendingVariantCreationTeamId) return; + pendingVariantCreationTeamId = teamId; + const teamName = teamsWithVariants.find((team) => team.id === teamId)?.name ?? null; + const createVariantRequest = (async () => { const res = await fetch(`/api/teams/${teamId}/variant`, { method: 'POST' }); - if (!res.ok) return; + if (!res.ok) { + throw new Error('Failed to create variant'); + } const result = await res.json(); - if (result?.id) { - await goto(resolve(`/team/${result.id}`)); + if (!result?.id) { + throw new Error('Variant response missing id'); } + return { + id: result.id as string, + name: (result.name as string | undefined) ?? 'New Variant' + }; + })(); + toastPromise(createVariantRequest, { + loading: teamName ? `Creating variant for "${teamName}"...` : 'Creating variant...', + success: (variant) => `Variant "${variant?.name ?? 'New Variant'}" ready`, + error: teamName ? `Unable to create ${teamName} variant` : 'Unable to create variant' + }); + try { + const createdVariant = await createVariantRequest; + await goto(resolve(`/team/${createdVariant.id}`)); } catch (error) { console.error('Error creating variant:', error); + } finally { + if (pendingVariantCreationTeamId === teamId) { + pendingVariantCreationTeamId = null; + } } }; @@ -160,20 +229,36 @@ cancelEditingTeam(); return; } - try { + if (savingTeamId) return; + savingTeamId = teamId; + const teamForToast = teamsWithVariants.find((team) => team.id === teamId); + const renameRequest = (async () => { const res = await fetch(`/api/teams/${teamId}/collection`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: nextName }) }); - if (res.ok) { - teamsWithVariants = teamsWithVariants.map((team) => - team.id === teamId ? { ...team, name: nextName } : team - ); + if (!res.ok) { + throw new Error('Failed to rename collection'); } - cancelEditingTeam(); + teamsWithVariants = teamsWithVariants.map((team) => + team.id === teamId ? { ...team, name: nextName } : team + ); + return nextName; + })(); + toastPromise(renameRequest, { + loading: teamForToast ? `Renaming "${teamForToast.name}"...` : 'Updating collection...', + success: (updatedName) => `Collection renamed to "${updatedName ?? nextName}"`, + error: teamForToast + ? `Unable to rename "${teamForToast.name}"` + : 'Unable to rename collection' + }); + try { + await renameRequest; } catch (error) { console.error('Error updating team name:', error); + } finally { + savingTeamId = null; cancelEditingTeam(); } }; @@ -309,6 +394,7 @@ size="icon" class="h-6 w-6" title="Save name" + disabled={savingTeamId === team.id} onclick={(event: MouseEvent) => { event.stopPropagation(); void saveTeamName(team.id); @@ -323,6 +409,7 @@ size="icon" class="h-6 w-6" title="Cancel" + disabled={savingTeamId === team.id} onclick={(event: MouseEvent) => { event.stopPropagation(); cancelEditingTeam(); @@ -351,6 +438,7 @@ size="icon" class="h-7 w-7" title="New variant" + disabled={pendingVariantCreationTeamId === team.id} onclick={(event: MouseEvent) => { event.stopPropagation(); void handleCreateVariant(team.id);