From c8df749258f2f4915e0b6900cbbd602a2bf8da3f Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Mon, 28 Sep 2026 19:48:08 +0200 Subject: [PATCH] feat(web): one sheet for sharing, downloads and version history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share & export is one 440px sheet (a full-height bottom sheet on phones) with Link, Download and History tabs. Share opens Link, the ▾ beside Download PDF opens Download, the clock opens History, and ⌘⇧S / ⌘⇧E open their tabs. On desktop the page moves 120px aside so it stays visible. - Link: the public switch card; the address with a live check (300 ms), the reason it can't be used and a suggestion, saved only once valid, so the old address stays live; Copy ("Copied" for 2 s); visitor downloads; the password (Q3a); Open public page; a QR code; Share via… where the platform offers it; and views, downloads and time since the last view over 30 days with a daily chart, or the explanation while the link is off. - Download: format cards explained by when to use them, Resume or Cover letter (with the resume header option) when the resume has a letter, the file name recruiters see (First-Last-Resume by default, unsafe characters stripped), a non-blocking note about open Check issues, and progress inside the button; a failure offers Try again and PDF. - History: name the current state; a timeline of Now, sessions, named versions, restores and where the document came from. Picking a version shows it on the page, read-only and outlined, with its banner; Restore saves "Before restore" first. Named versions can be renamed or deleted. Also: - Autosaves send this visit's session id, and naming or restoring a version saves pending edits first. - The one-click PDF and every export use the First-Last-Resume name. - Resume dialogs stop asking for a slug (it's edited in Share), which also stops Rename from overwriting a custom slug with one made from the name. - The public route redirects a renamed resume's old address. - Removed: the download dialog, the version-history menu and the sharing, statistics and export sections. --- apps/web/src/dialogs/resume/index.tsx | 74 +--- .../src/features/resume/builder/draft.test.ts | 22 +- apps/web/src/features/resume/builder/draft.ts | 15 +- apps/web/src/features/resume/editor/store.ts | 22 +- .../resume/editor}/use-open-issue-count.ts | 0 .../resume/export/download-dialog.tsx | 214 --------- .../resume/export/use-resume-export.ts | 82 ++-- .../features/resume/share/download-tab.tsx | 251 +++++++++++ .../src/features/resume/share/format.test.ts | 68 +++ apps/web/src/features/resume/share/format.ts | 90 ++++ .../src/features/resume/share/history-tab.tsx | 281 ++++++++++++ .../link-tab.test.tsx} | 87 +++- .../src/features/resume/share/link-tab.tsx | 406 ++++++++++++++++++ apps/web/src/routes/$username/$slug.tsx | 5 + .../$resumeId/-components/editor-bar.tsx | 66 ++- .../$resumeId/-components/page-canvas.tsx | 54 ++- .../$resumeId/-components/share-sheet.tsx | 86 ++-- .../-components/use-editor-hotkeys.ts | 6 +- .../$resumeId/-components/version-history.tsx | 101 ----- .../-sidebar/right/sections/export.test.tsx | 158 ------- .../-sidebar/right/sections/export.tsx | 39 -- .../-sidebar/right/sections/sharing.test.tsx | 97 ----- .../-sidebar/right/sections/sharing.tsx | 182 -------- .../right/sections/statistics.test.ts | 34 -- .../right/sections/statistics.test.tsx | 129 ------ .../-sidebar/right/sections/statistics.tsx | 136 ------ .../right/sections/statistics.utils.ts | 21 - .../src/icons/material-symbols-rounded.json | 3 + .../src/icons/material-symbols-rounded.woff2 | Bin 37240 -> 37892 bytes packages/ui/src/icons/names.ts | 3 + 30 files changed, 1417 insertions(+), 1315 deletions(-) rename apps/web/src/{routes/builder/$resumeId/-components => features/resume/editor}/use-open-issue-count.ts (100%) delete mode 100644 apps/web/src/features/resume/export/download-dialog.tsx create mode 100644 apps/web/src/features/resume/share/download-tab.tsx create mode 100644 apps/web/src/features/resume/share/format.test.ts create mode 100644 apps/web/src/features/resume/share/format.ts create mode 100644 apps/web/src/features/resume/share/history-tab.tsx rename apps/web/src/features/resume/{builder/sharing.test.tsx => share/link-tab.test.tsx} (65%) create mode 100644 apps/web/src/features/resume/share/link-tab.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-components/version-history.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/export.test.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/export.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/sharing.test.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.ts delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.tsx delete mode 100644 apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.utils.ts diff --git a/apps/web/src/dialogs/resume/index.tsx b/apps/web/src/dialogs/resume/index.tsx index e5229d112..283d412c8 100644 --- a/apps/web/src/dialogs/resume/index.tsx +++ b/apps/web/src/dialogs/resume/index.tsx @@ -3,10 +3,9 @@ import type { DialogProps } from "../store"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { CaretDownIcon, MagicWandIcon, PencilSimpleLineIcon, PlusIcon, TestTubeIcon } from "@phosphor-icons/react"; -import { useStore } from "@tanstack/react-form"; import { useMutation } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; +import { useRef } from "react"; import z from "zod"; import { Button } from "@reactive-resume/ui/components/button"; import { ButtonGroup } from "@reactive-resume/ui/components/button-group"; @@ -25,18 +24,11 @@ import { } from "@reactive-resume/ui/components/dropdown-menu"; import { FormControl, FormDescription, FormItem, FormLabel, FormMessage } from "@reactive-resume/ui/components/form"; import { Input } from "@reactive-resume/ui/components/input"; -import { - InputGroup, - InputGroupAddon, - InputGroupInput, - InputGroupText, -} from "@reactive-resume/ui/components/input-group"; import { toast } from "@reactive-resume/ui/components/toast"; -import { generateId, generateRandomName, slugify } from "@reactive-resume/utils/string"; +import { generateId, generateRandomName } from "@reactive-resume/utils/string"; import { ChipInput } from "@/components/input/chip-input"; import { usePatchResume } from "@/features/resume/builder/draft"; import { useFormBlocker } from "@/hooks/use-form-blocker"; -import { authClient } from "@/libs/auth/client"; import { getResumeErrorMessage } from "@/libs/error-message"; import { orpc } from "@/libs/orpc/client"; import { useAppForm, withForm } from "@/libs/tanstack-form"; @@ -45,7 +37,6 @@ import { useDialogStore } from "../store"; const formSchema = z.object({ id: z.string(), name: z.string().min(1).max(64), - slug: z.string().min(1).max(64).transform(slugify), tags: z.array(z.string()), }); @@ -54,7 +45,6 @@ type FormValues = z.infer; const defaultValues: FormValues = { id: "", name: "", - slug: "", tags: [], }; @@ -70,7 +60,6 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) { defaultValues: { id: generateId(), name: "", - slug: "", tags: [] as string[], }, validators: { onSubmit: formSchema }, @@ -91,12 +80,6 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) { }, }); - const name = useStore(form.store, (s) => s.values.name); - - useEffect(() => { - form.setFieldValue("slug", slugify(name)); - }, [form, name]); - useFormBlocker(form, { shouldBlock: () => !didCreateRef.current && form.state.isDirty && !form.state.isSubmitting, }); @@ -107,7 +90,6 @@ export function CreateResumeDialog(_: DialogProps<"resume.create">) { const data = { name: values.name || randomName, - slug: values.slug || slugify(randomName), tags: values.tags, withSampleData: true, } satisfies RouterInput["resume"]["create"]; @@ -195,7 +177,6 @@ export function UpdateResumeDialog({ data }: DialogProps<"resume.update">) { defaultValues: { id: data.id, name: data.name, - slug: data.slug, tags: data.tags, }, validators: { onSubmit: formSchema }, @@ -225,13 +206,6 @@ export function UpdateResumeDialog({ data }: DialogProps<"resume.update">) { }, }); - const name = useStore(form.store, (s) => s.values.name); - - useEffect(() => { - if (!name) return; - form.setFieldValue("slug", slugify(name)); - }, [form, name]); - useFormBlocker(form); return ( @@ -276,7 +250,6 @@ export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">) defaultValues: { id: data.id, name: `${data.name} (Copy)`, - slug: `${data.slug}-copy`, tags: data.tags, }, validators: { onSubmit: formSchema }, @@ -298,13 +271,6 @@ export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">) }, }); - const name = useStore(form.store, (s) => s.values.name); - - useEffect(() => { - if (!name) return; - form.setFieldValue("slug", slugify(name)); - }, [form, name]); - useFormBlocker(form); return ( @@ -342,10 +308,6 @@ export function DuplicateResumeDialog({ data }: DialogProps<"resume.duplicate">) const ResumeForm = withForm({ defaultValues, render: function ResumeFormRenderer({ form }) { - const { data: session } = authClient.useSession(); - - const slugPrefix = `${window.location.origin}/${session?.user.username ?? ""}/`; - const onGenerateName = () => { form.setFieldValue("name", generateRandomName()); }; @@ -384,38 +346,6 @@ const ResumeForm = withForm({ )} - - {(field) => ( - 0}> - - Slug - - - - {slugPrefix} - - field.handleChange(event.target.value)} - /> - - } - /> - - - This is a URL-friendly name for your resume. - - - )} - - {(field) => ( 0}> diff --git a/apps/web/src/features/resume/builder/draft.test.ts b/apps/web/src/features/resume/builder/draft.test.ts index 34ed2b4b8..b9c8900a3 100644 --- a/apps/web/src/features/resume/builder/draft.test.ts +++ b/apps/web/src/features/resume/builder/draft.test.ts @@ -321,7 +321,7 @@ describe("builder resume autosave", () => { expect(orpcMocks.updateResume).toHaveBeenCalledTimes(1); expect(orpcMocks.updateResume).toHaveBeenCalledWith( - { id: initial.id, data: updated.data }, + { id: initial.id, data: updated.data, sessionId: expect.any(String) }, expect.objectContaining({ signal: expect.any(AbortSignal) }), ); expect(orpcMocks.patchResume).not.toHaveBeenCalled(); @@ -342,7 +342,7 @@ describe("builder resume autosave", () => { await flushMicrotasks(); expect(orpcMocks.updateResume).toHaveBeenCalledWith( - { id: initial.id, data: updated.data }, + { id: initial.id, data: updated.data, sessionId: expect.any(String) }, expect.objectContaining({ signal: expect.any(AbortSignal) }), ); }); @@ -381,8 +381,16 @@ describe("builder resume autosave", () => { await flushMicrotasks(); expect(orpcMocks.updateResume).toHaveBeenCalledTimes(2); - expect(orpcMocks.updateResume.mock.calls[0]?.[0]).toEqual({ id: initial.id, data: first.data }); - expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({ id: initial.id, data: latest.data }); + expect(orpcMocks.updateResume.mock.calls[0]?.[0]).toEqual({ + id: initial.id, + data: first.data, + sessionId: expect.any(String), + }); + expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({ + id: initial.id, + data: latest.data, + sessionId: expect.any(String), + }); expect(orpcMocks.patchResume).not.toHaveBeenCalled(); }); @@ -419,7 +427,11 @@ describe("builder resume autosave", () => { await flushMicrotasks(); expect(orpcMocks.updateResume).toHaveBeenCalledTimes(2); - expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({ id: initial.id, data: latest.data }); + expect(orpcMocks.updateResume.mock.calls[1]?.[0]).toEqual({ + id: initial.id, + data: latest.data, + sessionId: expect.any(String), + }); }); it("keeps the latest draft on this device and reports Not saved when saving fails", async () => { diff --git a/apps/web/src/features/resume/builder/draft.ts b/apps/web/src/features/resume/builder/draft.ts index ff1634901..caf135812 100644 --- a/apps/web/src/features/resume/builder/draft.ts +++ b/apps/web/src/features/resume/builder/draft.ts @@ -96,6 +96,8 @@ type Runtime = { onlineHandler?: () => void; deferredRemoteResume?: Resume; deferredFocusHandler?: () => void; + /** This visit's saves share one autosave version in History. */ + sessionId: string; }; type ResumeUpdateSubscriptionOptions = { @@ -263,7 +265,7 @@ async function flushResumeSave(id: string) { try { const updated = (await orpc.resume.update.call( - { id: submitted.id, data: submittedData }, + { id: submitted.id, data: submittedData, sessionId: runtime.sessionId }, { signal: runtime.abortController.signal }, )) as Resume; @@ -338,6 +340,7 @@ function createRuntime(): Runtime { isSaving: false, saveFailed: false, syncResume, + sessionId: crypto.randomUUID(), }; if (typeof window !== "undefined") { @@ -788,8 +791,12 @@ export function useBuilderResumeUpdateSubscription() { useResumeUpdateSubscription({ resumeId, onUpdate, onError }); } -// Route transitions can await a save; unmount cleanup and browser unload cannot. -function saveResumeBeforeLeaving(id: string): boolean | Promise { +/** + * Saves pending edits now and resolves once they're on the server (false if saving fails). Route transitions + * await it before leaving, and History before naming or restoring a version, so the server has what's on + * screen. Unmount cleanup and browser unload can't await it. + */ +export function savePendingChanges(id: string): boolean | Promise { const runtime = runtimes.get(id); const current = useResumeStore.getState().resume; if (!runtime?.hasPendingLocalChanges || current?.id !== id) return true; @@ -833,7 +840,7 @@ export function useResumeCleanup() { useBlocker({ shouldBlockFn: async ({ next }) => { if (!resumeId || ("resumeId" in next.params && next.params.resumeId === resumeId)) return false; - return !(await saveResumeBeforeLeaving(resumeId)); + return !(await savePendingChanges(resumeId)); }, enableBeforeUnload: () => !!resumeId && (runtimes.get(resumeId)?.hasPendingLocalChanges ?? false), }); diff --git a/apps/web/src/features/resume/editor/store.ts b/apps/web/src/features/resume/editor/store.ts index 30dee2dc4..27e4348a2 100644 --- a/apps/web/src/features/resume/editor/store.ts +++ b/apps/web/src/features/resume/editor/store.ts @@ -5,6 +5,9 @@ import { create } from "zustand/react"; export const EDITOR_MODES = ["write", "design", "check"] as const; export type EditorMode = (typeof EDITOR_MODES)[number]; +/** The Share & export sheet's tabs; each entry point opens its own. */ +export type ShareTab = "link" | "download" | "history"; + /** What's selected in the editor: shared by the panel and the page, so each can outline the other. */ export type EditorSelection = PageMapTarget; @@ -22,8 +25,10 @@ type EditorStore = { drawerOpen: boolean; /** Tablet in landscape: the panel sits beside the page instead of over it. */ drawerPinned: boolean; - shareOpen: boolean; - downloadOpen: boolean; + /** The open tab of the Share & export sheet, or null while it's closed. */ + shareTab: ShareTab | null; + /** History: the version shown on the page, read-only, instead of the current resume. */ + historyVersionId: string | null; assistantOpen: boolean; /** Write: sections open in the outline. */ openSections: readonly string[]; @@ -41,8 +46,9 @@ type EditorStore = { setZoom: (zoom: number | "fit") => void; setDrawerOpen: (open: boolean) => void; setDrawerPinned: (pinned: boolean) => void; - setShareOpen: (open: boolean) => void; - setDownloadOpen: (open: boolean) => void; + /** Opens the sheet on a tab, or closes it (which also returns the page to now). */ + setShareTab: (tab: ShareTab | null) => void; + setHistoryVersion: (versionId: string | null) => void; setAssistantOpen: (open: boolean) => void; setSectionOpen: (sectionId: string, open: boolean) => void; markSectionAdded: (sectionId: string) => void; @@ -70,8 +76,8 @@ const initialState = { zoom: "fit", drawerOpen: false, drawerPinned: false, - shareOpen: false, - downloadOpen: false, + shareTab: null, + historyVersionId: null, assistantOpen: false, openSections: [], addedSections: [], @@ -87,8 +93,8 @@ export const useEditorStore = create()((set) => ({ setZoom: (zoom) => set({ zoom: zoom === "fit" ? "fit" : clampZoom(zoom) }), setDrawerOpen: (drawerOpen) => set({ drawerOpen }), setDrawerPinned: (drawerPinned) => set({ drawerPinned }), - setShareOpen: (shareOpen) => set({ shareOpen }), - setDownloadOpen: (downloadOpen) => set({ downloadOpen }), + setShareTab: (shareTab) => set(shareTab ? { shareTab } : { shareTab, historyVersionId: null }), + setHistoryVersion: (historyVersionId) => set({ historyVersionId }), setAssistantOpen: (assistantOpen) => set({ assistantOpen }), setSectionOpen: (sectionId, open) => set((state) => { diff --git a/apps/web/src/routes/builder/$resumeId/-components/use-open-issue-count.ts b/apps/web/src/features/resume/editor/use-open-issue-count.ts similarity index 100% rename from apps/web/src/routes/builder/$resumeId/-components/use-open-issue-count.ts rename to apps/web/src/features/resume/editor/use-open-issue-count.ts diff --git a/apps/web/src/features/resume/export/download-dialog.tsx b/apps/web/src/features/resume/export/download-dialog.tsx deleted file mode 100644 index 8e0f13637..000000000 --- a/apps/web/src/features/resume/export/download-dialog.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections"; -import type { ReactElement, ReactNode } from "react"; -import { Trans } from "@lingui/react/macro"; -import { - CircleNotchIcon, - DownloadSimpleIcon, - EnvelopeSimpleIcon, - FileDocIcon, - FileJsIcon, - FilePdfIcon, - FileTextIcon, - MarkdownLogoIcon, -} from "@phosphor-icons/react"; -import { useId, useState } from "react"; -import { Button } from "@reactive-resume/ui/components/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@reactive-resume/ui/components/dialog"; -import { Switch } from "@reactive-resume/ui/components/switch"; -import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs"; -import { cn } from "@reactive-resume/utils/style"; -import { useResumeExport } from "./use-resume-export"; - -type DownloadableResume = Parameters[0]; - -type ResumeDownloadDialogProps = { - resume: DownloadableResume; - /** Omitted when something else opens the dialog through `open`. */ - trigger?: (disabled: boolean) => ReactElement; - /** Controlled open state, e.g. for the ⌘⇧E shortcut. */ - open?: boolean; - onOpenChange?: (open: boolean) => void; -}; - -type FormatRowProps = { - action: ReactElement; - description: ReactNode; - disabled?: boolean; - icon: ReactElement; - title: ReactNode; -}; - -function FormatRow({ action, description, disabled, icon, title }: FormatRowProps) { - return ( -
-
- {icon} -
-
-

{title}

-

{description}

-
- {action} -
- ); -} - -export function ResumeDownloadDialog({ resume, trigger, open: openProp, onOpenChange }: ResumeDownloadDialogProps) { - const [uncontrolledOpen, setUncontrolledOpen] = useState(false); - const open = openProp ?? uncontrolledOpen; - const setOpen = onOpenChange ?? setUncontrolledOpen; - const [scope, setScope] = useState("resume"); - const [includeCoverLetterHeader, setIncludeCoverLetterHeader] = useState(false); - const includeHeaderSwitchId = useId(); - const { hasCoverLetter, isExporting, onDownloadDOCX, onDownloadJSON, onDownloadMarkdown, onDownloadPDF } = - useResumeExport(resume); - const disabled = !resume || isExporting; - - // Cover letter can't be the active scope when the resume has none (also guards a stale toggle). - const activeScope: ResumeExportTarget = scope === "cover-letter" && !hasCoverLetter ? "resume" : scope; - const jsonDisabled = activeScope === "cover-letter"; - - const run = (action: () => void | Promise) => { - setOpen(false); - void action(); - }; - - return ( - - {trigger && } - - - - Download - - - Pick a format for your resume or cover letter. - - - - setScope(value as ResumeExportTarget)}> - - - - Resume - - - - Cover letter - - - - - {activeScope === "cover-letter" && ( - - )} - -
- : - } - title="PDF" - description={Best for applications, sharing, and printing.} - action={ - - } - /> - - } - title="DOCX" - description={Editable in Word, Google Docs, and Pages.} - action={ - - } - /> - - } - title="Markdown" - description={Plain text, for AI tools and quick edits.} - action={ - - } - /> - - } - title="JSON" - description={Full resume data for backup or import.} - action={ - - } - /> -
-
-
- ); -} diff --git a/apps/web/src/features/resume/export/use-resume-export.ts b/apps/web/src/features/resume/export/use-resume-export.ts index 25fd1989a..c9154cb63 100644 --- a/apps/web/src/features/resume/export/use-resume-export.ts +++ b/apps/web/src/features/resume/export/use-resume-export.ts @@ -8,7 +8,7 @@ import { getResumeSectionTitle } from "@reactive-resume/pdf/section-title"; import { getResumeExportData, resumeHasCoverLetter } from "@reactive-resume/resume/export-sections"; import { buildMarkdown } from "@reactive-resume/resume/markdown"; import { toast } from "@reactive-resume/ui/components/toast"; -import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file"; +import { downloadWithAnchor } from "@reactive-resume/utils/file"; import { resolvePublicResumePdfBlob } from "@/features/resume/public/public-pdf"; import { client } from "@/libs/orpc/client"; import { createSectionTitleResolverForLocale } from "@/libs/resume/section-title-locale"; @@ -37,36 +37,71 @@ type UseResumeExportOptions = { publicResumePdf?: PublicResumePdfOptions; }; -const getExportName = (resume: ExportableResume) => resume.name || resume.data.basics.name || resume.slug; -const getTargetExportName = (resume: ExportableResume, target: ResumeExportTarget) => - target === "cover-letter" ? `${getExportName(resume)} Cover Letter` : getExportName(resume); - type DownloadPdfOptions = { includeCoverLetterHeader?: boolean; }; +export type ExportFormat = "pdf" | "docx" | "md" | "json"; + +// Characters Windows and macOS won't take in a file name. +const UNSAFE_FILE_NAME_CHARACTERS = /[\\/:*?"<>|]/g; + +/** A file name as the user typed it, minus the characters file systems reject. */ +export const sanitizeFileName = (value: string) => value.replace(UNSAFE_FILE_NAME_CHARACTERS, "").trim(); + /** - * Single source of truth for resume export (PDF / DOCX / JSON / Print). Previously duplicated verbatim - * between the builder dock and the right-panel Export section (#17). + * "First-Last-Resume" (or "First-Last-Cover-Letter"): recruiters see this name. Without a name on the + * resume it falls back to the document's name. */ +export function getDefaultFileName(resume: ExportableResume, target: ResumeExportTarget = "resume") { + const words = (text: string) => sanitizeFileName(text).split(/\s+/).filter(Boolean); + const person = words(resume.data.basics.name); + const suffix = target === "cover-letter" ? ["Cover", "Letter"] : ["Resume"]; + if (person.length > 0) return [...person, ...suffix].join("-"); + return [...words(resume.name || resume.slug), ...(target === "cover-letter" ? suffix : [])].join("-") || "Resume"; +} + +/** Builds one export file. It throws when the file can't be made, so each caller decides how to say so. */ +export async function createExportFile( + resume: ExportableResume, + format: ExportFormat, + target: ResumeExportTarget = "resume", + options?: DownloadPdfOptions, +): Promise { + if (format === "json") { + return new Blob([JSON.stringify(resume.data, null, 2)], { type: "application/json" }); + } + + const data = getResumeExportData(resume.data, target); + if (format === "pdf") { + return createResumePdfBlob( + data, + undefined, + target === "cover-letter" ? { includeCoverLetterHeader: options?.includeCoverLetterHeader } : undefined, + ); + } + + const resolveTitle = await createSectionTitleResolver(data); + if (format === "md") return new Blob([buildMarkdown(data, resolveTitle)], { type: "text/markdown" }); + return buildDocx(data, resolveTitle); +} + +/** One-click exports that report their own progress and failures in toasts (the bar, ⌘P, public pages). */ export function useResumeExport(resume: ExportableResume | undefined, exportOptions: UseResumeExportOptions = {}) { const [isExporting, setIsExporting] = useState(false); const hasCoverLetter = resume ? resumeHasCoverLetter(resume.data) : false; - const onDownloadJSON = useCallback(() => { + const onDownloadJSON = useCallback(async () => { if (!resume) return; - const blob = new Blob([JSON.stringify(resume.data, null, 2)], { type: "application/json" }); - downloadWithAnchor(blob, generateFilename(getExportName(resume), "json")); + downloadWithAnchor(await createExportFile(resume, "json"), `${getDefaultFileName(resume)}.json`); }, [resume]); const onDownloadMarkdown = useCallback( async (target: ResumeExportTarget = "resume") => { if (!resume) return; if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return; - const data = getResumeExportData(resume.data, target); - const resolveTitle = await createSectionTitleResolver(data); - const blob = new Blob([buildMarkdown(data, resolveTitle)], { type: "text/markdown" }); - downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "md")); + const blob = await createExportFile(resume, "md", target); + downloadWithAnchor(blob, `${getDefaultFileName(resume, target)}.md`); }, [resume], ); @@ -76,10 +111,8 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti if (!resume) return; if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return; try { - const data = getResumeExportData(resume.data, target); - const resolveTitle = await createSectionTitleResolver(data); - const blob = await buildDocx(data, resolveTitle); - downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "docx")); + const blob = await createExportFile(resume, "docx", target); + downloadWithAnchor(blob, `${getDefaultFileName(resume, target)}.docx`); } catch { toast.add({ type: "error", description: t`Could not generate the DOCX. Please try again.` }); } @@ -97,17 +130,10 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti }); setIsExporting(true); try { - const data = exportOptions.publicResumePdf ? resume.data : getResumeExportData(resume.data, target); const blob = exportOptions.publicResumePdf - ? await resolvePublicResumePdfBlob({ data, ...exportOptions.publicResumePdf }) - : await createResumePdfBlob( - data, - undefined, - target === "cover-letter" - ? { includeCoverLetterHeader: downloadOptions?.includeCoverLetterHeader } - : undefined, - ); - downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "pdf")); + ? await resolvePublicResumePdfBlob({ data: resume.data, ...exportOptions.publicResumePdf }) + : await createExportFile(resume, "pdf", target, downloadOptions); + downloadWithAnchor(blob, `${getDefaultFileName(resume, target)}.pdf`); if (exportOptions.publicResumePdf) { // Statistics are best effort and must not delay or fail a completed browser download. void client.resume.statistics diff --git a/apps/web/src/features/resume/share/download-tab.tsx b/apps/web/src/features/resume/share/download-tab.tsx new file mode 100644 index 000000000..4badd4f25 --- /dev/null +++ b/apps/web/src/features/resume/share/download-tab.tsx @@ -0,0 +1,251 @@ +import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections"; +import type { IconName } from "@reactive-resume/ui/components/icon"; +import type { ExportFormat } from "@/features/resume/export/use-resume-export"; +import { Radio } from "@base-ui/react/radio"; +import { RadioGroup } from "@base-ui/react/radio-group"; +import { t } from "@lingui/core/macro"; +import { Plural, Trans } from "@lingui/react/macro"; +import { useId, useState } from "react"; +import { resumeHasCoverLetter } from "@reactive-resume/resume/export-sections"; +import { Button } from "@reactive-resume/ui/components/button"; +import { Checkbox } from "@reactive-resume/ui/components/checkbox"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { SegmentedControl, SegmentedControlItem } from "@reactive-resume/ui/components/segmented-control"; +import { Spinner } from "@reactive-resume/ui/components/spinner"; +import { toast } from "@reactive-resume/ui/components/toast"; +import { downloadWithAnchor } from "@reactive-resume/utils/file"; +import { cn } from "@reactive-resume/utils/style"; +import { useCurrentResume } from "@/features/resume/builder/draft"; +import { useOpenIssueCount } from "@/features/resume/editor/use-open-issue-count"; +import { createExportFile, getDefaultFileName, sanitizeFileName } from "@/features/resume/export/use-resume-export"; + +type Format = { id: ExportFormat; label: string; extension: string; icon: IconName; description: string }; + +const getFormats = (): Format[] => [ + { + id: "pdf", + label: "PDF", + extension: ".pdf", + icon: "picture_as_pdf", + description: t`Looks exactly like the page. Use it for applications and email.`, + }, + { + id: "docx", + label: t`Word`, + extension: ".docx", + icon: "description", + description: t`For portals or recruiters who ask for Word. Layout is simplified.`, + }, + { + id: "md", + label: "Markdown", + extension: ".md", + icon: "notes", + description: t`Plain text with headings. Paste into application forms and notes.`, + }, + { + id: "json", + label: "JSON", + extension: ".json", + icon: "data_object", + description: t`Complete data backup. Imports back into Reactive Resume or JSON Resume tools.`, + }, +]; + +type DownloadTabProps = { + /** Opens Check; the note about open issues links there. */ + onReview: () => void; +}; + +/** + * Download: every format explained by when to use it, the file name recruiters see, and a button that shows + * its progress. Open Check issues are mentioned but never block. A failed file offers PDF instead. + */ +export function DownloadTab({ onReview }: DownloadTabProps) { + const resume = useCurrentResume(); + const issues = useOpenIssueCount(); + const fileNameId = useId(); + const hasLetter = resumeHasCoverLetter(resume.data); + const [target, setTarget] = useState("resume"); + const [format, setFormat] = useState("pdf"); + const [includeHeader, setIncludeHeader] = useState(false); + const [fileName, setFileName] = useState(null); + const [state, setState] = useState<"idle" | "busy" | "error">("idle"); + + const activeTarget = hasLetter ? target : "resume"; + // JSON is the whole document's data, so it isn't offered for the letter on its own. + const activeFormat = activeTarget === "cover-letter" && format === "json" ? "pdf" : format; + const formats = getFormats(); + const selected = formats.find((option) => option.id === activeFormat) ?? (formats[0] as Format); + const name = fileName ?? getDefaultFileName(resume, activeTarget); + + const download = async (as: ExportFormat) => { + const extension = formats.find((option) => option.id === as)?.extension ?? ".pdf"; + setState("busy"); + try { + const blob = await createExportFile(resume, as, activeTarget, { includeCoverLetterHeader: includeHeader }); + const file = `${sanitizeFileName(name) || getDefaultFileName(resume, activeTarget)}${extension}`; + downloadWithAnchor(blob, file); + setState("idle"); + toast.add({ description: t`Downloaded ${file}` }); + } catch { + setState("error"); + } + }; + + return ( +
+ {hasLetter && ( +
+ { + setTarget(value as ResumeExportTarget); + setFileName(null); + setState("idle"); + }} + className="w-full" + > + + Resume + + + Cover letter + + + {activeTarget === "cover-letter" && ( +
+ setIncludeHeader(checked === true)} + /> + +
+ )} +
+ )} + + { + setFormat(value as ExportFormat); + setState("idle"); + }} + className="grid gap-1.5" + > + {formats.map((option) => { + const unavailable = option.id === "json" && activeTarget === "cover-letter"; + return ( + + + + + + + {option.label} + {option.extension} + {option.id === "pdf" && ( + + Best for applying + + )} + + {option.description} + + + + + + ); + })} + + +
+ +
+ setFileName(sanitizeFileName(event.target.value))} + className="h-full min-w-0 flex-1 bg-transparent ps-2.5 font-medium font-mono text-[13px] text-ink outline-none" + /> + {selected.extension} +
+ + Recruiters see this name. Your name plus “Resume” works well. + +
+ + {issues > 0 && ( +
+ + + {" "} + + +
+ )} + + {state === "error" && ( +
+ + + The {selected.label} file couldn't be generated. Try again, or download PDF instead. + + {activeFormat !== "pdf" && ( + + )} +
+ )} + + +
+ ); +} diff --git a/apps/web/src/features/resume/share/format.test.ts b/apps/web/src/features/resume/share/format.test.ts new file mode 100644 index 000000000..8d9087392 --- /dev/null +++ b/apps/web/src/features/resume/share/format.test.ts @@ -0,0 +1,68 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { i18n } from "@lingui/core"; +import { formatTimeSince, formatVersionTime, getVersionTitle, summarizeViews } from "./format"; + +beforeAll(() => i18n.loadAndActivate({ locale: "en-US", messages: {} })); + +const day = (date: string, views: number, downloads = 0) => ({ date, views, downloads }); + +describe("summarizeViews", () => { + it("totals the last 30 days and scales each day against the peak", () => { + const daily = [ + day("2026-08-01", 50), + ...Array.from({ length: 29 }, (_, index) => day(`d${index}`, 1, 1)), + day("today", 4, 2), + ]; + + const summary = summarizeViews(daily); + + expect(summary.bars).toHaveLength(30); + expect(summary.views).toBe(29 + 4); + expect(summary.downloads).toBe(29 + 2); + expect(summary.peak).toBe(4); + expect(summary.bars.at(-1)?.height).toBe(1); + expect(summary.bars[0]?.height).toBe(0.25); + }); + + it("draws flat bars when nobody has looked yet", () => { + expect(summarizeViews([day("a", 0), day("b", 0)])).toMatchObject({ + views: 0, + peak: 0, + bars: [{ height: 0 }, { height: 0 }], + }); + }); +}); + +describe("formatTimeSince", () => { + const now = new Date("2026-09-28T12:00:00Z").getTime(); + const ago = (ms: number) => new Date(now - ms); + + it.each([ + [5 * 60 * 1000, "5m"], + [2 * 60 * 60 * 1000, "2h"], + [3 * 24 * 60 * 60 * 1000, "3d"], + [20 * 24 * 60 * 60 * 1000, "2w"], + [400 * 24 * 60 * 60 * 1000, "1y"], + ])("writes %i ms as %s", (elapsed, expected) => { + expect(formatTimeSince(ago(elapsed), "en-US", now)).toBe(expected); + }); +}); + +describe("formatVersionTime", () => { + const now = new Date(2026, 8, 28, 15, 0); + + it("names today and yesterday, then dates, with the year only when it differs", () => { + expect(formatVersionTime(new Date(2026, 8, 28, 14, 2), "en-US", now)).toMatch(/^Today 0?2:02 PM$|^Today 14:02$/); + expect(formatVersionTime(new Date(2026, 8, 27, 9, 15), "en-US", now)).toMatch(/^Yesterday/); + expect(formatVersionTime(new Date(2026, 8, 16), "en-US", now)).toBe("Sep 16"); + expect(formatVersionTime(new Date(2025, 8, 16), "en-US", now)).toBe("Sep 16, 2025"); + }); +}); + +describe("getVersionTitle", () => { + it("uses the user's name for a named version and the kind otherwise", () => { + expect(getVersionTitle({ kind: "named", name: "Sent to Lumen" })).toBe("Sent to Lumen"); + expect(getVersionTitle({ kind: "import", name: null })).toBe("Imported"); + expect(getVersionTitle({ kind: "auto", name: null })).toBe("Editing session"); + }); +}); diff --git a/apps/web/src/features/resume/share/format.ts b/apps/web/src/features/resume/share/format.ts new file mode 100644 index 000000000..f771cd6ea --- /dev/null +++ b/apps/web/src/features/resume/share/format.ts @@ -0,0 +1,90 @@ +import type { RouterOutput } from "@/libs/orpc/client"; +import { t } from "@lingui/core/macro"; + +type DailyStat = { date: string; views: number; downloads: number }; +export type VersionSummary = RouterOutput["resume"]["listVersions"][number]; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The Link tab's numbers: views and downloads over the last 30 days, and each day's views against the peak. */ +export function summarizeViews(daily: readonly DailyStat[]) { + const days = daily.slice(-30); + const peak = Math.max(0, ...days.map((day) => day.views)); + + return { + views: days.reduce((sum, day) => sum + day.views, 0), + downloads: days.reduce((sum, day) => sum + day.downloads, 0), + peak, + bars: days.map((day) => ({ date: day.date, views: day.views, height: peak > 0 ? day.views / peak : 0 })), + }; +} + +const UNITS = [ + { unit: "minute", ms: 60 * 1000, below: 60 }, + { unit: "hour", ms: 60 * 60 * 1000, below: 24 }, + { unit: "day", ms: DAY_MS, below: 7 }, + { unit: "week", ms: 7 * DAY_MS, below: 52 }, + { unit: "year", ms: 365 * DAY_MS, below: Number.POSITIVE_INFINITY }, +] as const; + +/** Time since a moment in the largest whole unit, written short: "5m", "2h", "3d", "6w", "1y". */ +export function formatTimeSince(date: Date, locale: string, now = Date.now()) { + const elapsed = Math.max(0, now - date.getTime()); + const { unit, ms } = UNITS.find(({ ms, below }) => elapsed / ms < below) ?? UNITS[UNITS.length - 1]; + return new Intl.NumberFormat(locale, { style: "unit", unit, unitDisplay: "narrow" }).format(Math.floor(elapsed / ms)); +} + +/** "Today 14:02", "Yesterday 09:15", "Sep 16", or "Sep 16, 2025" for another year. */ +export function formatVersionTime(date: Date, locale: string, now = new Date()) { + const time = date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + + if (date.getTime() >= startOfToday) return t`Today ${time}`; + if (date.getTime() >= startOfToday - DAY_MS) return t`Yesterday ${time}`; + return date.toLocaleDateString(locale, { + month: "short", + day: "numeric", + ...(date.getFullYear() === now.getFullYear() ? {} : { year: "numeric" }), + }); +} + +/** A version's full date and time, for sentences: "Sep 28, 2026, 7:36 PM". */ +export const formatVersionMoment = (date: Date, locale: string) => + date.toLocaleString(locale, { dateStyle: "medium", timeStyle: "short" }); + +/** A version's title in History: its name when the user named it, otherwise what made it. */ +export function getVersionTitle(version: Pick) { + switch (version.kind) { + case "named": + return version.name || t`Named version`; + case "created": + return t`Created`; + case "import": + return t`Imported`; + case "auto": + return t`Editing session`; + case "before-restore": + return t`Before restore`; + case "restored": + return t`Restored a version`; + case "ai": + return t`AI edit`; + case "sent": + return t`Sent`; + } +} + +/** The line under a version's title: how it was saved. */ +export function getVersionDetail(version: Pick) { + switch (version.kind) { + case "named": + return t`named`; + case "auto": + case "before-restore": + return t`autosaved`; + case "ai": + return t`from the assistant or API`; + default: + return t`saved`; + } +} diff --git a/apps/web/src/features/resume/share/history-tab.tsx b/apps/web/src/features/resume/share/history-tab.tsx new file mode 100644 index 000000000..bf469cc45 --- /dev/null +++ b/apps/web/src/features/resume/share/history-tab.tsx @@ -0,0 +1,281 @@ +import type { Resume } from "@/features/resume/builder/draft"; +import type { VersionSummary } from "./format"; +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { Button } from "@reactive-resume/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@reactive-resume/ui/components/dropdown-menu"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { IconButton } from "@reactive-resume/ui/components/icon-button"; +import { Input } from "@reactive-resume/ui/components/input"; +import { toast } from "@reactive-resume/ui/components/toast"; +import { cn } from "@reactive-resume/utils/style"; +import { savePendingChanges, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft"; +import { useEditorStore } from "@/features/resume/editor/store"; +import { useConfirm } from "@/hooks/use-confirm"; +import { usePrompt } from "@/hooks/use-prompt"; +import { getResumeErrorMessage } from "@/libs/error-message"; +import { orpc } from "@/libs/orpc/client"; +import { formatVersionMoment, formatVersionTime, getVersionDetail, getVersionTitle } from "./format"; + +/** + * History: name the current state, or pick any version to see it on the page, read-only, then restore it or + * go back to now. Restoring saves the current state as "Before restore" first, so it can be undone. + */ +export function HistoryTab() { + const resume = useCurrentResume(); + const queryClient = useQueryClient(); + const { i18n } = useLingui(); + const selectedId = useEditorStore((state) => state.historyVersionId); + const setVersion = useEditorStore((state) => state.setHistoryVersion); + const [name, setName] = useState(""); + + const listKey = orpc.resume.listVersions.queryKey({ input: { resumeId: resume.id } }); + const { data: versions, isPending: loading } = useQuery( + orpc.resume.listVersions.queryOptions({ input: { resumeId: resume.id } }), + ); + const createVersion = useMutation(orpc.resume.createVersion.mutationOptions()); + const restoreVersion = useMutation(orpc.resume.restoreVersion.mutationOptions()); + + const selected = versions?.find((version) => version.id === selectedId) ?? null; + const when = (version: VersionSummary) => formatVersionTime(version.createdAt, i18n.locale); + + const save = async () => { + const trimmed = name.trim(); + if (!trimmed) return; + try { + await savePendingChanges(resume.id); + await createVersion.mutateAsync({ resumeId: resume.id, name: trimmed }); + setName(""); + void queryClient.invalidateQueries({ queryKey: listKey }); + toast.add({ description: t`Saved “${trimmed}”` }); + } catch (error) { + toast.add({ type: "error", description: getResumeErrorMessage(error) }); + } + }; + + const restore = async (version: VersionSummary) => { + try { + await savePendingChanges(resume.id); + const restored = await restoreVersion.mutateAsync({ resumeId: resume.id, versionId: version.id }); + useResumeStore.getState().replaceResumeFromServer(restored as Resume); + queryClient.setQueryData(orpc.resume.getById.queryKey({ input: { id: resume.id } }), restored); + setVersion(null); + void queryClient.invalidateQueries({ queryKey: listKey }); + toast.add({ + description: t`Restored the version from ${formatVersionMoment(version.createdAt, i18n.locale)}. Your previous state is saved as “Before restore”.`, + }); + } catch (error) { + toast.add({ type: "error", description: getResumeErrorMessage(error) }); + } + }; + + return ( +
+
{ + event.preventDefault(); + void save(); + }} + > + setName(event.target.value)} + className="h-9" + /> + +
+ + {selected && ( +
+ + + + Viewing {when(selected)} · {getVersionTitle(selected)} · read-only + + + + + + +
+ )} + +
    + setVersion(null)} + /> + {versions?.map((version, index) => ( + setVersion(version.id)} + menu={version.kind === "named" ? : null} + /> + ))} +
+ + {!loading && (versions?.length ?? 0) <= 1 && ( +

+ Only one version so far. Every editing session adds one automatically. +

+ )} + +

+ + Autosaves are grouped by session and kept for 90 days. Named versions are kept until you delete them. + Restoring saves the current state first. + +

+
+ ); +} + +type TimelineItemProps = { + title: string; + detail: string; + selected: boolean; + last: boolean; + onSelect: () => void; + current?: boolean; + named?: boolean; + menu?: React.ReactNode; +}; + +function TimelineItem({ title, detail, selected, last, onSelect, current, named, menu }: TimelineItemProps) { + return ( +
  • +
  • + ); +} + +/** Named versions are the user's own: they can be renamed or deleted. The rest expire on their own. */ +function NamedVersionMenu({ version }: { version: VersionSummary }) { + const resume = useCurrentResume(); + const queryClient = useQueryClient(); + const prompt = usePrompt(); + const confirm = useConfirm(); + const selectedId = useEditorStore((state) => state.historyVersionId); + const setVersion = useEditorStore((state) => state.setHistoryVersion); + const renameVersion = useMutation(orpc.resume.renameVersion.mutationOptions()); + const deleteVersion = useMutation(orpc.resume.deleteVersion.mutationOptions()); + const refresh = () => + queryClient.invalidateQueries({ queryKey: orpc.resume.listVersions.queryKey({ input: { resumeId: resume.id } }) }); + const title = getVersionTitle(version); + + const rename = async () => { + const name = (await prompt(t`Rename version`, { defaultValue: version.name ?? "" }))?.trim(); + if (!name || name === version.name) return; + try { + await renameVersion.mutateAsync({ resumeId: resume.id, versionId: version.id, name }); + void refresh(); + } catch (error) { + toast.add({ type: "error", description: getResumeErrorMessage(error) }); + } + }; + + const remove = async () => { + const confirmed = await confirm(t`Delete “${title}”?`, { + description: t`Named versions are kept until you delete them. This can't be undone.`, + confirmText: t`Delete`, + }); + if (!confirmed) return; + try { + await deleteVersion.mutateAsync({ resumeId: resume.id, versionId: version.id }); + if (selectedId === version.id) setVersion(null); + void refresh(); + toast.add({ description: t`Deleted “${title}”` }); + } catch (error) { + toast.add({ type: "error", description: getResumeErrorMessage(error) }); + } + }; + + return ( + + + } + /> + + void rename()}> + + Rename… + + void remove()}> + + Delete + + + + ); +} diff --git a/apps/web/src/features/resume/builder/sharing.test.tsx b/apps/web/src/features/resume/share/link-tab.test.tsx similarity index 65% rename from apps/web/src/features/resume/builder/sharing.test.tsx rename to apps/web/src/features/resume/share/link-tab.test.tsx index d30c49278..8213964a1 100644 --- a/apps/web/src/features/resume/builder/sharing.test.tsx +++ b/apps/web/src/features/resume/share/link-tab.test.tsx @@ -7,17 +7,18 @@ import { I18nProvider } from "@lingui/react"; import { ORPCError } from "@orpc/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PromptDialogProvider } from "@/hooks/use-prompt"; -import { SharingSectionBuilder } from "@/routes/builder/$resumeId/-sidebar/right/sections/sharing"; - -type SectionBaseProps = { children: React.ReactNode }; +import { LinkTab } from "./link-tab"; const mocks = vi.hoisted(() => ({ setPassword: vi.fn(), + update: vi.fn(), + checkSlug: vi.fn(), patchResume: vi.fn(), + resume: { id: "resume-id", name: "Resume", slug: "resume", isPublic: true, hasPassword: false, isLocked: false }, })); vi.mock("@/features/resume/builder/draft", () => ({ - useCurrentResume: () => ({ id: "resume-id", slug: "resume", isPublic: true, hasPassword: false }), + useCurrentResume: () => mocks.resume, usePatchResume: () => mocks.patchResume, })); vi.mock("@/libs/auth/client", () => ({ @@ -27,16 +28,28 @@ vi.mock("@/libs/orpc/client", () => ({ orpc: { resume: { setPassword: { mutationOptions: () => ({ mutationFn: mocks.setPassword }) }, - update: { mutationOptions: () => ({ mutationFn: vi.fn() }) }, + update: { mutationOptions: () => ({ mutationFn: mocks.update }) }, removePassword: { mutationOptions: () => ({ mutationFn: vi.fn() }) }, + checkSlug: { + queryOptions: ({ input }: { input: { slug: string } }) => ({ + queryKey: ["checkSlug", input.slug], + queryFn: () => mocks.checkSlug(input), + }), + }, + statistics: { + getById: { queryOptions: () => ({ queryKey: ["stats"], queryFn: async () => ({ lastViewedAt: null }) }) }, + getDailyById: { queryOptions: () => ({ queryKey: ["daily"], queryFn: async () => [] }) }, + }, }, }, })); vi.mock("@/hooks/use-confirm", () => ({ useConfirm: () => vi.fn() })); -vi.mock("@/routes/builder/$resumeId/-sidebar/right/shared/section-base", () => ({ - SectionBase: ({ children }: SectionBaseProps) =>
    {children}
    , -})); vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: vi.fn(), close: vi.fn() } })); +vi.mock("usehooks-ts", async (importOriginal) => ({ + ...(await importOriginal()), + // The address check waits 300 ms after typing; tests skip the wait. + useDebounceValue: (value: T) => [value, vi.fn()], +})); beforeAll(() => { i18n.loadAndActivate({ locale: "en", messages: {} }); @@ -44,21 +57,26 @@ beforeAll(() => { beforeEach(() => { vi.clearAllMocks(); mocks.setPassword.mockResolvedValue(undefined); + mocks.update.mockImplementation(async (input: { slug?: string }) => ({ ...mocks.resume, ...input })); }); afterEach(cleanup); -async function openDialog() { +function renderTab() { render( - + - + , ); +} + +async function openDialog() { + renderTab(); await act(() => { - fireEvent.click(screen.getByRole("button", { name: "Set Password" })); + fireEvent.click(screen.getByRole("switch", { name: "Require a password" })); }); return within(screen.queryByRole("dialog") ?? screen.getByRole("alertdialog")); } @@ -156,10 +174,53 @@ describe("resume password sharing", () => { await act(() => { fireEvent.click(dialog.getByRole("button", { name: "Cancel" })); }); + // Cancelling leaves the switch off; turning it on again starts over. + expect(screen.getByRole("switch", { name: "Require a password" })).not.toBeChecked(); await act(() => { - fireEvent.click(screen.getByRole("button", { name: "Set Password" })); + fireEvent.click(screen.getByRole("switch", { name: "Require a password" })); }); expect((screen.getByLabelText("Password", { exact: true }) as HTMLInputElement).value).toBe(""); expect(mocks.setPassword).not.toHaveBeenCalled(); }); }); + +describe("the address", () => { + const address = () => screen.getByRole("textbox", { name: "Address" }); + const type = (value: string) => + act(() => { + fireEvent.change(address(), { target: { value } }); + }); + + it("explains the pattern without asking the server", async () => { + renderTab(); + await type("My Resume!"); + + expect(address()).toHaveValue("my-resume!"); + expect(screen.getByText("Use lowercase letters, numbers and single dashes.")).toBeInTheDocument(); + expect(mocks.checkSlug).not.toHaveBeenCalled(); + }); + + it("names the resume using a taken address and applies the suggestion", async () => { + mocks.checkSlug.mockResolvedValueOnce({ status: "taken", takenBy: "Resume 2024", suggestion: "resume-lumen" }); + renderTab(); + await type("taken"); + + expect(await screen.findByText("You already use this for “Resume 2024”.")).toBeInTheDocument(); + mocks.checkSlug.mockResolvedValueOnce({ status: "available" }); + await act(() => { + fireEvent.click(screen.getByRole("button", { name: "Try resume-lumen" })); + }); + expect(address()).toHaveValue("resume-lumen"); + }); + + it("saves a new address once it checks out, keeping the old one until then", async () => { + mocks.checkSlug.mockResolvedValueOnce({ status: "available" }); + renderTab(); + await type("product-designer"); + + await vi.waitFor(() => + expect(mocks.update).toHaveBeenCalledWith({ id: "resume-id", slug: "product-designer" }, expect.anything()), + ); + expect(mocks.patchResume).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/features/resume/share/link-tab.tsx b/apps/web/src/features/resume/share/link-tab.tsx new file mode 100644 index 000000000..69177302a --- /dev/null +++ b/apps/web/src/features/resume/share/link-tab.tsx @@ -0,0 +1,406 @@ +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { ORPCError } from "@orpc/client"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { QRCodeSVG } from "qrcode.react"; +import { useEffect, useId, useRef, useState } from "react"; +import { useCopyToClipboard, useDebounceValue } from "usehooks-ts"; +import { Button, buttonVariants } from "@reactive-resume/ui/components/button"; +import { Icon } from "@reactive-resume/ui/components/icon"; +import { Popover, PopoverContent, PopoverTrigger } from "@reactive-resume/ui/components/popover"; +import { Separator } from "@reactive-resume/ui/components/separator"; +import { SwitchRow } from "@reactive-resume/ui/components/switch"; +import { toast } from "@reactive-resume/ui/components/toast"; +import { cn } from "@reactive-resume/utils/style"; +import { useCurrentResume, usePatchResume } from "@/features/resume/builder/draft"; +import { ResumePasswordDialog } from "@/features/resume/builder/password-dialog"; +import { useConfirm } from "@/hooks/use-confirm"; +import { authClient } from "@/libs/auth/client"; +import { orpc } from "@/libs/orpc/client"; +import { formatTimeSince, summarizeViews } from "./format"; + +const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const COPIED_MS = 2000; + +const errorMessage = (error: unknown) => + error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`; + +/** + * Link: the public switch, the address with its live check, copy, downloads for visitors, open, QR code and + * the password, then views and downloads. Private is the default; turning the link off keeps the address. + */ +export function LinkTab() { + const resume = useCurrentResume(); + const patchResume = usePatchResume(); + const { data: session } = authClient.useSession(); + const { mutateAsync: updateResume, isPending } = useMutation(orpc.resume.update.mutationOptions()); + const isPublic = resume.isPublic ?? false; + const url = `${window.location.origin}/${session?.user.username ?? ""}/${resume.slug}`; + + const setPublic = async (checked: boolean) => { + try { + const updated = await updateResume({ id: resume.id, isPublic: checked }); + patchResume((draft) => { + draft.isPublic = updated.isPublic; + }); + toast.add({ description: checked ? t`Link is live` : t`Link turned off. The address is kept` }); + } catch (error) { + toast.add({ type: "error", description: errorMessage(error) }); + } + }; + + const setVisitorDownloads = async (checked: boolean) => { + try { + const updated = await updateResume({ id: resume.id, showDownloadButtons: checked }); + patchResume((draft) => { + draft.showDownloadButtons = updated.showDownloadButtons; + }); + } catch (error) { + toast.add({ type: "error", description: errorMessage(error) }); + } + }; + + return ( +
    + void setPublic(checked)} + label={{t`Public link`}} + description={ + isPublic + ? t`On. Anyone with the link can view. It isn't listed or indexed by search engines.` + : t`Off. Only you can see this resume.` + } + className="items-start rounded-xl border border-line p-3.5 transition-colors duration-standard data-checked:border-accent data-checked:bg-accent-soft" + /> + + + + {isPublic && ( +
    + void setVisitorDownloads(checked)} + label={t`Visitors can download the PDF`} + className="py-0" + /> + + +
    + )} + + + +
    + ); +} + +type AddressFieldProps = { url: string; username: string }; + +/** The address, checked as you type (300 ms). The old one stays live until the new one is valid and saved. */ +function AddressField({ url, username }: AddressFieldProps) { + const resume = useCurrentResume(); + const patchResume = usePatchResume(); + const id = useId(); + const isPublic = resume.isPublic ?? false; + const [slug, setSlug] = useState(resume.slug); + const [debouncedSlug] = useDebounceValue(slug, 300); + const [copied, setCopied] = useState(false); + const [, copyToClipboard] = useCopyToClipboard(); + const copiedTimer = useRef(undefined); + const { mutateAsync: updateResume } = useMutation(orpc.resume.update.mutationOptions()); + + const changed = slug !== resume.slug; + const wellFormed = SLUG_PATTERN.test(slug); + const check = useQuery({ + ...orpc.resume.checkSlug.queryOptions({ input: { resumeId: resume.id, slug: debouncedSlug } }), + enabled: isPublic && changed && wellFormed && debouncedSlug === slug, + }); + const result = debouncedSlug === slug ? check.data : undefined; + + // Save as soon as a new address checks out; until then the current one keeps working. + const saving = useRef(null); + useEffect(() => { + if (result?.status !== "available" || saving.current === slug) return; + saving.current = slug; + updateResume({ id: resume.id, slug }) + .then((updated) => + patchResume((draft) => { + draft.slug = updated.slug; + }), + ) + .catch((error: unknown) => toast.add({ type: "error", description: errorMessage(error) })) + .finally(() => { + saving.current = null; + }); + }, [result?.status, slug, resume.id, updateResume, patchResume]); + + // A save here, another tab or a restore can change the address; the field follows it. + useEffect(() => setSlug(resume.slug), [resume.slug]); + + const invalid = isPublic && (!slug || !wellFormed || result?.status === "taken"); + const message = !isPublic + ? t`Turn on the link to choose an address.` + : !slug + ? t`Add an address.` + : !wellFormed + ? t`Use lowercase letters, numbers and single dashes.` + : !changed + ? t`Live at ${url.replace(/^https?:\/\//, "")}` + : result?.status === "taken" + ? null + : result?.status === "available" + ? t`Available · ${window.location.host}/${username}/${slug}` + : t`Checking…`; + + const copy = async () => { + await copyToClipboard(url); + setCopied(true); + window.clearTimeout(copiedTimer.current); + copiedTimer.current = window.setTimeout(() => setCopied(false), COPIED_MS); + toast.add({ description: t`Link copied` }); + }; + + const canShare = typeof navigator.share === "function"; + + return ( +
    + +
    +
    + {/* A long host or username truncates, so the part being edited stays visible. */} + + {window.location.host}/{username}/ + + setSlug(event.target.value.toLowerCase().replace(/\s+/g, "-"))} + className="h-full min-w-0 flex-1 bg-transparent pe-1.5 font-medium font-mono text-[13px] text-ink outline-none" + /> + {isPublic && slug && ( + + )} +
    + +
    +

    + {result?.status === "taken" ? ( + <> + {result.takenBy ? ( + You already use this for “{result.takenBy}”. + ) : ( + Another of your resumes uses this address. + )}{" "} + {result.suggestion && ( + + )} + + ) : ( + message + )} +

    + {isPublic && canShare && ( + + )} +
    + ); +} + +/** Q3a: visitors enter a password before they see the resume. */ +function PasswordRow() { + const resume = useCurrentResume(); + const patchResume = usePatchResume(); + const confirm = useConfirm(); + const [dialogOpen, setDialogOpen] = useState(false); + const { mutateAsync: setPassword } = useMutation(orpc.resume.setPassword.mutationOptions()); + const { mutateAsync: removePassword } = useMutation(orpc.resume.removePassword.mutationOptions()); + + const turnOff = async () => { + const confirmed = await confirm(t`Remove the password?`, { + description: t`Anyone with the link will be able to view your resume.`, + confirmText: t`Remove`, + }); + if (!confirmed) return; + try { + await removePassword({ id: resume.id }); + patchResume((draft) => { + draft.hasPassword = false; + }); + toast.add({ description: t`Password removed` }); + } catch (error) { + toast.add({ type: "error", description: errorMessage(error) }); + } + }; + + return ( + <> + (checked ? setDialogOpen(true) : void turnOff())} + label={t`Require a password`} + description={ + resume.hasPassword + ? t`Visitors enter it before they see the resume. Share it only with people you trust.` + : undefined + } + className="py-0" + /> + {dialogOpen && ( + setDialogOpen(false)} + onSubmit={async (password) => { + await setPassword({ id: resume.id, password }); + patchResume((draft) => { + draft.hasPassword = true; + }); + toast.add({ description: t`Password set` }); + }} + /> + )} + + ); +} + +function QrCodeButton({ url }: { url: string }) { + return ( + + + + QR code + + } + /> + + + + + ); +} + +/** Only the owner sees these; counts are anonymous and kept for 90 days. */ +function ViewsAndDownloads({ isPublic }: { isPublic: boolean }) { + const resume = useCurrentResume(); + const { i18n } = useLingui(); + const { data: statistics } = useQuery(orpc.resume.statistics.getById.queryOptions({ input: { id: resume.id } })); + const { data: daily } = useQuery({ + ...orpc.resume.statistics.getDailyById.queryOptions({ input: { id: resume.id, days: 30 } }), + enabled: isPublic, + }); + const summary = summarizeViews(daily ?? []); + const lastView = statistics?.lastViewedAt ? formatTimeSince(statistics.lastViewedAt, i18n.locale) : "—"; + const firstDay = daily?.[0]?.date; + + return ( +
    +
    +

    + Views and downloads +

    + + Only you see these + +
    + + {isPublic ? ( + <> +
    + + + +
    +
    + {summary.bars.map((bar) => ( + + ))} +
    +
    + + {firstDay + ? new Date(`${firstDay}T00:00:00`).toLocaleDateString(i18n.locale, { month: "short", day: "numeric" }) + : ""} + + + Today + +
    + + ) : ( +

    + + Turn on the public link to count views and downloads. Counts are anonymous and kept for 90 days. + +

    + )} +
    + ); +} + +function Stat({ value, label }: { value: number | string; label: string }) { + return ( +
    +
    {label}
    +
    {value}
    +
    + ); +} diff --git a/apps/web/src/routes/$username/$slug.tsx b/apps/web/src/routes/$username/$slug.tsx index 23dc18346..7005f17b3 100644 --- a/apps/web/src/routes/$username/$slug.tsx +++ b/apps/web/src/routes/$username/$slug.tsx @@ -17,6 +17,11 @@ export const Route = createFileRoute("/$username/$slug")({ orpc.resume.getBySlug.queryOptions({ input: { username, slug } }), ); + // A renamed resume's old address still finds it for 30 days; send visitors to the current one. + if (resume.slug !== slug) { + throw redirect({ to: "/$username/$slug", params: { username, slug: resume.slug }, replace: true }); + } + return { resume: resume as LoaderData }; }, head: ({ loaderData, params }) => { diff --git a/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx b/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx index 0da097210..79850da49 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/editor-bar.tsx @@ -11,12 +11,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/com import { cn } from "@reactive-resume/utils/style"; import { useCurrentBuilderResumeSelector, useCurrentResume, useResumeStore } from "@/features/resume/builder/draft"; import { useEditorStore } from "@/features/resume/editor/store"; -import { ResumeDownloadDialog } from "@/features/resume/export/download-dialog"; +import { useOpenIssueCount } from "@/features/resume/editor/use-open-issue-count"; import { useResumeExport } from "@/features/resume/export/use-resume-export"; import { BuilderAiAssistant } from "./ai-assistant"; import { DocumentMenu } from "./document-menu"; -import { useOpenIssueCount } from "./use-open-issue-count"; -import { BuilderVersionHistory } from "./version-history"; type EditorBarProps = { layout: "desktop" | "tablet" | "mobile"; @@ -49,7 +47,7 @@ export function EditorBar({ layout, pinnable }: EditorBarProps) { {layout === "desktop" && ( <> - + @@ -170,6 +168,13 @@ function DrawerControls({ pinnable }: { pinnable: boolean }) { ); } +/** History lives in the Share & export sheet; the clock opens it there. */ +function HistoryButton() { + const setShareTab = useEditorStore((state) => state.setShareTab); + + return setShareTab("history")} />; +} + type ToolbarActionProps = { compact: boolean; disabled: boolean; @@ -177,7 +182,7 @@ type ToolbarActionProps = { function ShareButton({ compact, disabled }: ToolbarActionProps) { const isPublic = useCurrentBuilderResumeSelector((resume) => resume.isPublic ?? false); - const setShareOpen = useEditorStore((state) => state.setShareOpen); + const setShareTab = useEditorStore((state) => state.setShareTab); const liveDot = isPublic ? ( @@ -198,7 +203,7 @@ function ShareButton({ compact, disabled }: ToolbarActionProps) { } return ( - - ( - - )} - /> + ); } diff --git a/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx b/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx index 28b95013b..56427d011 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/page-canvas.tsx @@ -1,7 +1,9 @@ import type { EditorSelection } from "@/features/resume/editor/store"; import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; import { Plural, Trans } from "@lingui/react/macro"; import { useHotkey } from "@tanstack/react-hotkeys"; +import { useQuery } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; import { Icon } from "@reactive-resume/ui/components/icon"; import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint"; @@ -13,6 +15,8 @@ import { PageOverlay } from "@/features/resume/editor/page-overlay"; import { useEditorStore, ZOOM_MAX, ZOOM_MIN, ZOOM_STEP } from "@/features/resume/editor/store"; import { revealSelectionInPanel } from "@/features/resume/editor/write/reveal"; import { ResumePreview } from "@/features/resume/preview/preview"; +import { formatVersionTime, getVersionTitle } from "@/features/resume/share/format"; +import { orpc } from "@/libs/orpc/client"; import { useEditorMode } from "./use-editor-mode"; // Page widths in PDF points; 1pt renders as 1 CSS px at 100%. @@ -46,6 +50,10 @@ export function PageCanvas() { const select = useEditorStore((state) => state.select); const setDrawerOpen = useEditorStore((state) => state.setDrawerOpen); const previewTemplate = useEditorStore((state) => state.previewTemplate); + const historyVersionId = useEditorStore((state) => state.historyVersionId); + const sheetOpen = useEditorStore((state) => state.shareTab !== null); + const resumeId = useCurrentBuilderResumeSelector((resume) => resume.id); + const { i18n } = useLingui(); const rendered = useEditorStore((state) => state.rendered); const setRendered = useEditorStore((state) => state.setRendered); const breakpoint = useBreakpoint(); @@ -58,13 +66,26 @@ export function PageCanvas() { const pageScale = zoom === "fit" ? Math.max(0.25, fitScale) : zoom; const formatLabel = { a4: "A4", letter: t`Letter`, "free-form": t`Free-form` }[format]; + // History: the picked version is drawn on the page, read-only, until the user restores it or goes back to now. + const { data: version } = useQuery({ + ...orpc.resume.getVersion.queryOptions({ input: { resumeId, versionId: historyVersionId ?? "" } }), + enabled: historyVersionId !== null, + }); + const viewing = historyVersionId !== null && version?.id === historyVersionId ? version : null; + // Design: a hovered or focused template is drawn on the page until it's applied or the pointer leaves. const previewData = useMemo( () => - data && previewTemplate ? { ...data, metadata: { ...data.metadata, template: previewTemplate } } : undefined, - [data, previewTemplate], + viewing + ? viewing.data + : data && previewTemplate + ? { ...data, metadata: { ...data.metadata, template: previewTemplate } } + : undefined, + [data, previewTemplate, viewing], ); - const overflow = data && !previewTemplate ? measureOverflow(data, rendered) : null; + const overflow = data && !previewTemplate && !viewing ? measureOverflow(data, rendered) : null; + // Desktop: the page moves 120px aside so it stays visible beside the Share & export sheet. + const shifted = sheetOpen && (breakpoint === "desktop" || breakpoint === "wide"); const onSelect = (selection: EditorSelection) => { select(selection); @@ -92,13 +113,27 @@ export function PageCanvas() { pageLayout="vertical" pageGap={24} pageScale={pageScale} - className="mx-auto w-fit" - pageClassName="rounded-none shadow-page" + className={cn( + "mx-auto w-fit transition-transform duration-emphasized ease-enter", + shifted && "-translate-x-[120px] rtl:translate-x-[120px]", + )} + pageClassName={cn("rounded-none shadow-page", viewing && "outline-2 outline-ink outline-offset-4")} onRender={setRendered} renderPageCaption={({ pageNumber }) => pageNumber === 1 ? (
    - {previewTemplate ? ( + {viewing ? ( + + + + Viewing {formatVersionTime(viewing.createdAt, i18n.locale)} · {getVersionTitle(viewing)} · + read-only + + + ) : previewTemplate ? ( ) } - renderPageOverlay={({ pageIndex, pageMap }) => ( - - )} + renderPageOverlay={({ pageIndex, pageMap }) => + // A version from History is read-only: its lines don't open entries. + viewing ? null : + } /> diff --git a/apps/web/src/routes/builder/$resumeId/-components/share-sheet.tsx b/apps/web/src/routes/builder/$resumeId/-components/share-sheet.tsx index 88bfc58ba..8146e2c5c 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/share-sheet.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/share-sheet.tsx @@ -1,41 +1,75 @@ +import type { ShareTab } from "@/features/resume/editor/store"; +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { Button } from "@reactive-resume/ui/components/button"; -import { Icon } from "@reactive-resume/ui/components/icon"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@reactive-resume/ui/components/sheet"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs"; +import { useBreakpoint } from "@reactive-resume/ui/hooks/use-breakpoint"; +import { cn } from "@reactive-resume/utils/style"; import { useEditorStore } from "@/features/resume/editor/store"; -import { SharingSectionBuilder } from "../-sidebar/right/sections/sharing"; -import { StatisticsSectionBuilder } from "../-sidebar/right/sections/statistics"; +import { DownloadTab } from "@/features/resume/share/download-tab"; +import { HistoryTab } from "@/features/resume/share/history-tab"; +import { LinkTab } from "@/features/resume/share/link-tab"; +import { useEditorMode } from "./use-editor-mode"; -/** The public link, its statistics and every download format. History joins this sheet in M5. */ +/** + * Share & export: one sheet with Link, Download and History. Share opens Link, the ▾ beside Download PDF + * opens Download and the clock opens History. 440px from the right; a full-height bottom sheet on phones. + */ export function ShareSheet() { - const open = useEditorStore((state) => state.shareOpen); - const setOpen = useEditorStore((state) => state.setShareOpen); - const setDownloadOpen = useEditorStore((state) => state.setDownloadOpen); + const tab = useEditorStore((state) => state.shareTab); + const setTab = useEditorStore((state) => state.setShareTab); + const setHistoryVersion = useEditorStore((state) => state.setHistoryVersion); + const [, setMode] = useEditorMode(); + const isPhone = useBreakpoint() === "mobile"; return ( - - - + !open && setTab(null)}> + + Share & export -
    - - - -
    + { + // The page shows a version only while History is open. + if (value !== "history") setHistoryVersion(null); + setTab(value as ShareTab); + }} + className="min-h-0 flex-1 gap-0" + > + + + Link + + + Download + + + History + + + + + + + { + setTab(null); + setMode("check"); + }} + /> + + + + +
    ); diff --git a/apps/web/src/routes/builder/$resumeId/-components/use-editor-hotkeys.ts b/apps/web/src/routes/builder/$resumeId/-components/use-editor-hotkeys.ts index 4013f1c6a..af7fee96c 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/use-editor-hotkeys.ts +++ b/apps/web/src/routes/builder/$resumeId/-components/use-editor-hotkeys.ts @@ -17,7 +17,7 @@ export function useEditorHotkeys(setMode: (mode: EditorMode) => void) { const redo = useResumeStore((state) => state.redo); const resume = useCurrentResume(); const { onDownloadPDF } = useResumeExport(resume); - const { select, setShareOpen, setDownloadOpen, setAssistantOpen } = useEditorStore.getState(); + const { select, setShareTab, setAssistantOpen } = useEditorStore.getState(); const isOnline = () => useResumeStore.getState().saveStatus !== "offline"; useHotkey("1", () => setMode("write")); @@ -38,10 +38,10 @@ export function useEditorHotkeys(setMode: (mode: EditorMode) => void) { if (isOnline()) void onDownloadPDF(); }); useHotkey("Mod+Shift+S", () => { - if (isOnline()) setShareOpen(true); + if (isOnline()) setShareTab("link"); }); useHotkey("Mod+Shift+E", () => { - if (isOnline()) setDownloadOpen(true); + if (isOnline()) setShareTab("download"); }); useHotkey("Mod+J", () => setAssistantOpen(!useEditorStore.getState().assistantOpen)); useHotkey("Escape", () => select(null), { preventDefault: false, stopPropagation: false }); diff --git a/apps/web/src/routes/builder/$resumeId/-components/version-history.tsx b/apps/web/src/routes/builder/$resumeId/-components/version-history.tsx deleted file mode 100644 index c88c14b41..000000000 --- a/apps/web/src/routes/builder/$resumeId/-components/version-history.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import type { Resume } from "@/features/resume/builder/draft"; -import { i18n } from "@lingui/core"; -import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useMemo, useState } from "react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@reactive-resume/ui/components/dropdown-menu"; -import { IconButton } from "@reactive-resume/ui/components/icon-button"; -import { toast } from "@reactive-resume/ui/components/toast"; -import { useResumeStore } from "@/features/resume/builder/draft"; -import { useConfirm } from "@/hooks/use-confirm"; -import { getResumeErrorMessage } from "@/libs/error-message"; -import { formatRelativeTime } from "@/libs/locale"; -import { orpc } from "@/libs/orpc/client"; - -type BuilderVersionHistoryProps = { - resumeId: string; -}; - -export function BuilderVersionHistory({ resumeId }: BuilderVersionHistoryProps) { - const [open, setOpen] = useState(false); - const confirm = useConfirm(); - const queryClient = useQueryClient(); - const replaceResumeFromServer = useResumeStore((state) => state.replaceResumeFromServer); - - const relativeTimeFormatter = useMemo(() => new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" }), []); - - const { data: versions, isLoading } = useQuery({ - ...orpc.resume.listVersions.queryOptions({ input: { resumeId } }), - enabled: open, - }); - - const { mutateAsync: restoreVersion, isPending } = useMutation(orpc.resume.restoreVersion.mutationOptions()); - - const handleRestore = async (versionId: string) => { - const confirmed = await confirm(t`Restore this version?`, { - description: t`Earlier versions are kept; the builder's undo history is reset.`, - }); - - if (!confirmed) return; - - try { - const restored = await restoreVersion({ resumeId, versionId }); - replaceResumeFromServer(restored as Resume); - queryClient.setQueryData(orpc.resume.getById.queryOptions({ input: { id: resumeId } }).queryKey, restored); - void queryClient.invalidateQueries({ queryKey: orpc.resume.listVersions.queryKey({ input: { resumeId } }) }); - toast.add({ type: "success", description: t`Your resume has been restored to the selected version.` }); - } catch (error) { - toast.add({ type: "error", description: getResumeErrorMessage(error) }); - } - }; - - return ( - - } /> - - - - - Version history - - - - {isLoading && ( -
    - Loading… -
    - )} - - {!isLoading && (!versions || versions.length === 0) && ( -
    - No saved versions yet. -
    - )} - - {versions?.map((version) => ( - handleRestore(version.id)} - > - {version.label} - - {formatRelativeTime(version.createdAt, relativeTimeFormatter)} - - - ))} -
    -
    -
    - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/export.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/export.test.tsx deleted file mode 100644 index 7d192a770..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/export.test.tsx +++ /dev/null @@ -1,158 +0,0 @@ -// @vitest-environment happy-dom - -import type { ResumeData } from "@reactive-resume/schema/resume/data"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; -import { defaultResumeData } from "@reactive-resume/schema/resume/default"; -import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; - -const downloadWithAnchor = vi.hoisted(() => vi.fn()); -const buildDocx = vi.hoisted(() => vi.fn().mockResolvedValue(new Blob(["x"], { type: "application/x-docx" }))); -const createResumePdfBlob = vi.hoisted(() => vi.fn().mockResolvedValue(new Blob(["x"], { type: "application/pdf" }))); -const resumeMock = vi.hoisted(() => ({ - resume: undefined as - | undefined - | { - id: string; - name: string; - slug: string; - data: ResumeData; - }, -})); - -type SectionBaseProps = { - children: React.ReactNode; -}; - -vi.mock("../shared/section-base", () => ({ - SectionBase: ({ children }: SectionBaseProps) =>
    {children}
    , -})); -vi.mock("@reactive-resume/utils/file", () => ({ - downloadWithAnchor, - generateFilename: (name: string, ext: string) => `${name}.${ext}`, -})); -vi.mock("@reactive-resume/docx", () => ({ buildDocx })); -vi.mock("@/features/resume/export/pdf-document", () => ({ createResumePdfBlob })); -// DOCX/Markdown resolve locale-aware section titles; stub the async locale resolver so exports -// fall back to the built-in English titles without loading real locale catalogs. -vi.mock("@/libs/resume/section-title-locale", () => ({ - createSectionTitleResolverForLocale: vi.fn().mockResolvedValue(() => undefined), -})); -vi.mock("@/features/resume/builder/draft", () => ({ - useResume: () => resumeMock.resume, -})); - -const { ExportSectionBuilder } = await import("./export"); - -beforeAll(() => { - i18n.loadAndActivate({ locale: "en", messages: {} }); -}); - -beforeEach(() => { - const data = structuredClone(defaultResumeData); - data.metadata.stylesheet = { - mode: "semantic", - source: { languageVersion: 1, text: "@version 1;\nname {" }, - }; - resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data }; -}); - -afterEach(() => { - downloadWithAnchor.mockReset(); - buildDocx.mockClear(); - createResumePdfBlob.mockClear(); -}); - -const renderExport = () => - render( - - - , - ); - -const openDialog = () => { - const trigger = screen.getByText( - "Choose PDF, DOCX, Markdown, or JSON. Export your resume and cover letter separately when available.", - ); - fireEvent.click(trigger.closest("button") as HTMLButtonElement); -}; - -describe("ExportSectionBuilder", () => { - it("renders the PDF, DOCX, Markdown, and JSON format rows", () => { - renderExport(); - openDialog(); - - expect(screen.getByRole("button", { name: "Download PDF" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Download DOCX" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Download Markdown" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Download JSON" })).toBeInTheDocument(); - }); - - it("downloads a Markdown blob when the Markdown button is clicked", async () => { - renderExport(); - openDialog(); - fireEvent.click(screen.getByRole("button", { name: "Download Markdown" })); - - await waitFor(() => expect(downloadWithAnchor).toHaveBeenCalledTimes(1)); - // biome-ignore lint/style/noNonNullAssertion: The assertion above verifies the download call exists before destructuring it. - const [blob, filename] = downloadWithAnchor.mock.calls[0]!; - expect((blob as Blob).type).toBe("text/markdown"); - expect(filename).toBe("My Resume.md"); - }); - - it("downloads the current stylesheet source in JSON", async () => { - renderExport(); - openDialog(); - fireEvent.click(screen.getByRole("button", { name: "Download JSON" })); - - expect(downloadWithAnchor).toHaveBeenCalledTimes(1); - // biome-ignore lint/style/noNonNullAssertion: The assertion above verifies the download call exists before destructuring it. - const [blob, filename] = downloadWithAnchor.mock.calls[0]!; - expect(blob).toBeInstanceOf(Blob); - expect((blob as Blob).type).toBe("application/json"); - expect(filename).toBe("My Resume.json"); - const exported = JSON.parse(await (blob as Blob).text()); - expect(exported.metadata.stylesheet).toEqual(resumeMock.resume?.data.metadata.stylesheet); - }); - - it("calls buildDocx and downloads the resulting blob when DOCX is clicked", async () => { - renderExport(); - openDialog(); - fireEvent.click(screen.getByRole("button", { name: "Download DOCX" })); - - await waitFor(() => expect(buildDocx).toHaveBeenCalledTimes(1)); - expect(downloadWithAnchor).toHaveBeenCalledTimes(1); - expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.docx"); - }); - - it("calls createResumePdfBlob and downloads when PDF is clicked", async () => { - renderExport(); - openDialog(); - fireEvent.click(screen.getByRole("button", { name: "Download PDF" })); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - - expect(createResumePdfBlob).toHaveBeenCalledTimes(1); - expect(createResumePdfBlob).toHaveBeenCalledWith(resumeMock.resume?.data, undefined, undefined); - expect(downloadWithAnchor).toHaveBeenCalledTimes(1); - expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.pdf"); - }); - - it("exports the cover letter when the scope is switched and a cover letter exists", async () => { - resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data: sampleResumeData }; - renderExport(); - openDialog(); - - fireEvent.click(screen.getByRole("tab", { name: "Cover letter" })); - fireEvent.click(screen.getByRole("button", { name: "Download PDF" })); - await Promise.resolve(); - await Promise.resolve(); - await Promise.resolve(); - - expect(createResumePdfBlob).toHaveBeenCalledTimes(1); - expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume Cover Letter.pdf"); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/export.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/export.tsx deleted file mode 100644 index 53afb5525..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/export.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Trans } from "@lingui/react/macro"; -import { DownloadSimpleIcon } from "@phosphor-icons/react"; -import { Button } from "@reactive-resume/ui/components/button"; -import { useResume } from "@/features/resume/builder/draft"; -import { ResumeDownloadDialog } from "@/features/resume/export/download-dialog"; -import { SectionBase } from "../shared/section-base"; - -export function ExportSectionBuilder() { - const resume = useResume(); - - if (!resume) return null; - - return ( - - ( - - )} - /> - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/sharing.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/sharing.test.tsx deleted file mode 100644 index 6e2077525..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/sharing.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -// @vitest-environment happy-dom - -import type { ReactNode } from "react"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; - -const mocks = vi.hoisted(() => ({ - resume: { - id: "resume-id", - slug: "resume", - isPublic: true, - isLocked: false, - hasPassword: false, - showDownloadButtons: true, - }, - update: vi.fn(), - patch: vi.fn(), - toast: vi.fn(), -})); -vi.mock("@tanstack/react-query", () => ({ useMutation: () => ({ mutateAsync: mocks.update, isPending: false }) })); -vi.mock("@/features/resume/builder/draft", () => ({ - useCurrentResume: () => mocks.resume, - usePatchResume: () => mocks.patch, -})); -vi.mock("@/libs/auth/client", () => ({ - authClient: { useSession: () => ({ data: { user: { username: "owner" } } }) }, -})); -vi.mock("@/libs/orpc/client", () => ({ - orpc: { - resume: { - update: { mutationOptions: () => ({}) }, - setPassword: { mutationOptions: () => ({}) }, - removePassword: { mutationOptions: () => ({}) }, - }, - }, -})); -vi.mock("@/hooks/use-confirm", () => ({ useConfirm: () => vi.fn() })); -vi.mock("@/hooks/use-prompt", () => ({ usePrompt: () => vi.fn() })); -vi.mock("@reactive-resume/ui/components/toast", () => ({ toast: { add: mocks.toast } })); -vi.mock("../shared/section-base", () => ({ - SectionBase: ({ children }: { children: ReactNode }) =>
    {children}
    , -})); - -const { SharingSectionBuilder } = await import("./sharing"); - -beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} })); -beforeEach(() => { - vi.clearAllMocks(); - mocks.resume = { - id: "resume-id", - slug: "resume", - isPublic: true, - isLocked: false, - hasPassword: false, - showDownloadButtons: true, - }; - mocks.update.mockResolvedValue({ ...mocks.resume, showDownloadButtons: false }); - mocks.patch.mockImplementation((update: (draft: typeof mocks.resume) => void) => update(mocks.resume)); -}); - -const renderSharing = () => - render( - - - , - ); - -describe("sharing download preference", () => { - it("saves the per-resume preference when its switch is turned off", async () => { - renderSharing(); - const toggle = screen.getByRole("switch", { name: "Show Download Buttons" }); - expect(toggle).toBeChecked(); - fireEvent.click(toggle); - await waitFor(() => expect(mocks.update).toHaveBeenCalledWith({ id: "resume-id", showDownloadButtons: false })); - await waitFor(() => expect(mocks.resume.showDownloadButtons).toBe(false)); - }); - - it("keeps the saved preference when persistence fails", async () => { - mocks.update.mockRejectedValueOnce(new Error("Unavailable")); - renderSharing(); - fireEvent.click(screen.getByRole("switch", { name: "Show Download Buttons" })); - await waitFor(() => expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ type: "error" }))); - expect(mocks.patch).not.toHaveBeenCalled(); - expect(mocks.resume.showDownloadButtons).toBe(true); - }); - - it("disables changes while the resume is locked", () => { - mocks.resume.isLocked = true; - renderSharing(); - const toggle = screen.getByRole("switch", { name: "Show Download Buttons" }); - expect(toggle).toHaveAttribute("aria-disabled", "true"); - fireEvent.click(toggle); - expect(mocks.update).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx deleted file mode 100644 index bec9db752..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { ORPCError } from "@orpc/client"; -import { ClipboardIcon, LockSimpleIcon, LockSimpleOpenIcon } from "@phosphor-icons/react"; -import { useMutation } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; -import { useCopyToClipboard } from "usehooks-ts"; -import { Button } from "@reactive-resume/ui/components/button"; -import { Input } from "@reactive-resume/ui/components/input"; -import { Label } from "@reactive-resume/ui/components/label"; -import { Switch } from "@reactive-resume/ui/components/switch"; -import { toast } from "@reactive-resume/ui/components/toast"; -import { useCurrentResume, usePatchResume } from "@/features/resume/builder/draft"; -import { ResumePasswordDialog } from "@/features/resume/builder/password-dialog"; -import { useConfirm } from "@/hooks/use-confirm"; -import { authClient } from "@/libs/auth/client"; -import { orpc } from "@/libs/orpc/client"; -import { SectionBase } from "../shared/section-base"; - -export function SharingSectionBuilder() { - const [isPasswordDialogOpen, setIsPasswordDialogOpen] = useState(false); - const confirm = useConfirm(); - const [_, copyToClipboard] = useCopyToClipboard(); - const { data: session } = authClient.useSession(); - const resume = useCurrentResume(); - const patchResume = usePatchResume(); - - const { mutateAsync: updateResume, isPending: isUpdating } = useMutation(orpc.resume.update.mutationOptions()); - const { mutateAsync: setPassword } = useMutation(orpc.resume.setPassword.mutationOptions()); - const { mutateAsync: removePassword } = useMutation(orpc.resume.removePassword.mutationOptions()); - - const publicUrl = session ? `${window.location.origin}/${session.user.username}/${resume.slug}` : ""; - - const onCopyUrl = useCallback(async () => { - await copyToClipboard(publicUrl); - toast.add({ type: "success", description: t`Resume link copied to clipboard.` }); - }, [publicUrl, copyToClipboard]); - - const onTogglePublic = useCallback( - async (checked: boolean) => { - try { - const updated = await updateResume({ id: resume.id, isPublic: checked }); - patchResume((draft) => { - draft.isPublic = updated.isPublic; - }); - } catch (error) { - const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`; - toast.add({ type: "error", description: message }); - } - }, - [patchResume, resume.id, updateResume], - ); - - const onToggleDownloadButtons = useCallback( - async (checked: boolean) => { - try { - const updated = await updateResume({ id: resume.id, showDownloadButtons: checked }); - patchResume((draft) => { - draft.showDownloadButtons = updated.showDownloadButtons; - }); - } catch (error) { - const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`; - toast.add({ type: "error", description: message }); - } - }, - [patchResume, resume.id, updateResume], - ); - - const onSetPassword = useCallback( - async (password: string) => { - await setPassword({ id: resume.id, password }); - patchResume((draft) => { - draft.hasPassword = true; - }); - toast.add({ type: "success", description: t`Password protection has been enabled.` }); - }, - [patchResume, resume.id, setPassword], - ); - - const onRemovePassword = useCallback(async () => { - if (!resume.hasPassword) return; - - const confirmation = await confirm(t`Are you sure you want to remove password protection?`, { - description: t`Anyone with the public URL will be able to view your resume without a password.`, - confirmText: t`Confirm`, - cancelText: t`Cancel`, - }); - if (!confirmation) return; - - const toastId = toast.add({ type: "loading", description: t`Removing password protection...` }); - - try { - await removePassword({ id: resume.id }); - patchResume((draft) => { - draft.hasPassword = false; - }); - toast.add({ type: "success", description: t`Password protection has been disabled.`, id: toastId }); - } catch (error) { - const message = error instanceof ORPCError ? error.message : t`Something went wrong. Please try again.`; - toast.add({ type: "error", description: message, id: toastId }); - } - }, [confirm, patchResume, removePassword, resume.hasPassword, resume.id]); - - const isPasswordProtected = resume.hasPassword; - - return ( - - {isPasswordDialogOpen && ( - setIsPasswordDialogOpen(false)} /> - )} -
    - void onTogglePublic(checked)} - /> - - -
    - - {resume.isPublic && ( -
    -
    - void onToggleDownloadButtons(checked)} - /> - -
    - -
    - - -
    - - - -
    -
    - -

    - {isPasswordProtected ? ( - - Your resume's public URL is protected by a password. Share the password only with people you trust. - - ) : ( - Set a password if you want only people who know it to open the public URL. - )} -

    - - {isPasswordProtected ? ( - - ) : ( - - )} -
    - )} -
    - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.ts b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.ts deleted file mode 100644 index c899d8afa..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { computeDelta, getSparklinePoints } from "./statistics.utils"; - -describe("computeDelta", () => { - it("returns null when the prior period had no activity", () => { - // Prior 2 days are all zero -> no baseline to compare against. - expect(computeDelta([0, 0, 5, 5], 2)).toBeNull(); - }); - - it("returns a positive percentage when the recent period grew", () => { - // previous sum = 2, recent sum = 4 -> +100% - expect(computeDelta([1, 1, 2, 2], 2)).toBe(100); - }); - - it("returns a negative percentage when the recent period shrank", () => { - // previous sum = 4, recent sum = 2 -> -50% - expect(computeDelta([2, 2, 1, 1], 2)).toBe(-50); - }); -}); - -describe("getSparklinePoints", () => { - it("returns null for a single point", () => { - expect(getSparklinePoints([5], 80, 24)).toBeNull(); - }); - - it("returns null for an all-zero series", () => { - expect(getSparklinePoints([0, 0, 0], 80, 24)).toBeNull(); - }); - - it("returns points for all-equal non-zero values (flat line at the top)", () => { - // max === value, so every y is 0; x spans 0..width. - expect(getSparklinePoints([3, 3, 3], 80, 24)).toBe("0.0,0.0 40.0,0.0 80.0,0.0"); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.tsx deleted file mode 100644 index bcf660b38..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -// @vitest-environment happy-dom - -import { render, screen } from "@testing-library/react"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { i18n } from "@lingui/core"; -import { I18nProvider } from "@lingui/react"; - -const queryResult = vi.hoisted(() => ({ - data: undefined as - | undefined - | { - isPublic: boolean; - views: number; - downloads: number; - lastViewedAt: Date | null; - lastDownloadedAt: Date | null; - }, -})); - -const dailyResult = vi.hoisted(() => ({ - data: undefined as undefined | { date: string; views: number; downloads: number }[], -})); - -type SectionBaseProps = { - children: React.ReactNode; -}; - -vi.mock("@tanstack/react-query", () => ({ - useQuery: (options: { __key?: string }) => (options.__key === "daily" ? dailyResult : queryResult), -})); -vi.mock("@tanstack/react-router", () => ({ - useParams: () => ({ resumeId: "r1" }), -})); -vi.mock("@/libs/orpc/client", () => ({ - orpc: { - resume: { - statistics: { - getById: { queryOptions: () => ({ __key: "getById" }) }, - getDailyById: { queryOptions: () => ({ __key: "daily" }) }, - }, - }, - }, -})); -vi.mock("../shared/section-base", () => ({ - SectionBase: ({ children }: SectionBaseProps) =>
    {children}
    , -})); - -const { StatisticsSectionBuilder } = await import("./statistics"); - -beforeAll(() => { - i18n.loadAndActivate({ locale: "en", messages: {} }); -}); - -beforeEach(() => { - queryResult.data = undefined; - dailyResult.data = undefined; -}); - -const renderStats = () => - render( - - - , - ); - -describe("StatisticsSectionBuilder", () => { - it("renders nothing while the query result is undefined", () => { - const { container } = renderStats(); - expect(container.textContent).toBe(""); - }); - - it("renders the private hint when isPublic=false", () => { - queryResult.data = { - isPublic: false, - views: 0, - downloads: 0, - lastViewedAt: null, - lastDownloadedAt: null, - }; - renderStats(); - expect(screen.getByText("Track your resume's views and downloads")).toBeInTheDocument(); - }); - - it("renders the views/downloads counters when isPublic=true", () => { - queryResult.data = { - isPublic: true, - views: 42, - downloads: 7, - lastViewedAt: null, - lastDownloadedAt: null, - }; - renderStats(); - expect(screen.getByText("42")).toBeInTheDocument(); - expect(screen.getByText("7")).toBeInTheDocument(); - expect(screen.getByText("Views")).toBeInTheDocument(); - expect(screen.getByText("Downloads")).toBeInTheDocument(); - }); - - it("renders a prior-period delta from the daily series", () => { - queryResult.data = { - isPublic: true, - views: 30, - downloads: 0, - lastViewedAt: null, - lastDownloadedAt: null, - }; - // 60 days: prior 30 sum to 10, recent 30 sum to 20 -> +100%. - dailyResult.data = Array.from({ length: 60 }, (_, i) => ({ - date: `2024-01-${String(i + 1).padStart(2, "0")}`, - views: i < 30 ? (i < 10 ? 1 : 0) : i < 50 ? 1 : 0, - downloads: 0, - })); - renderStats(); - expect(screen.getByText(/\+100%/)).toBeInTheDocument(); - }); - - it("renders 'last viewed/downloaded' timestamps when present", () => { - queryResult.data = { - isPublic: true, - views: 1, - downloads: 0, - lastViewedAt: new Date("2024-01-15T00:00:00Z"), - lastDownloadedAt: null, - }; - renderStats(); - // Just verify some 'Last viewed' copy appears — the date formatting depends on the runner's locale. - expect(screen.getByText(/Last viewed/i)).toBeInTheDocument(); - }); -}); diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.tsx deleted file mode 100644 index d0703928a..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { InfoIcon } from "@phosphor-icons/react"; -import { useQuery } from "@tanstack/react-query"; -import { useParams } from "@tanstack/react-router"; -import { Accordion, AccordionContent, AccordionItem } from "@reactive-resume/ui/components/accordion"; -import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert"; -import { cn } from "@reactive-resume/utils/style"; -import { orpc } from "@/libs/orpc/client"; -import { SectionBase } from "../shared/section-base"; -import { computeDelta, getSparklinePoints } from "./statistics.utils"; - -// Fetch 60 days so we can render a 30-day sparkline and compare it against the prior 30 days. -const TREND_DAYS = 60; -const WINDOW = 30; - -export function StatisticsSectionBuilder() { - const params = useParams({ from: "/builder/$resumeId" }); - const { data: statistics } = useQuery( - orpc.resume.statistics.getById.queryOptions({ input: { id: params.resumeId } }), - ); - const { data: daily } = useQuery( - orpc.resume.statistics.getDailyById.queryOptions({ - input: { id: params.resumeId, days: TREND_DAYS }, - enabled: Boolean(statistics?.isPublic), - }), - ); - - if (!statistics) return null; - - const viewsSeries = daily?.map((day) => day.views) ?? []; - const downloadsSeries = daily?.map((day) => day.downloads) ?? []; - - return ( - - - - - - - - Track your resume's views and downloads - - - - Turn on public sharing to track how many times your resume has been viewed or downloaded. Only you can - see these numbers. - - - - - - - - - - - - - - - - ); -} - -type StatisticsItemProps = { - label: string; - value: number; - series: number[]; - timestamp: string | null; -}; - -function StatisticsItem({ label, value, series, timestamp }: StatisticsItemProps) { - const recent = series.slice(-WINDOW); - const delta = computeDelta(series, WINDOW); - - return ( -
    -
    -

    {value}

    - -
    -

    {label}

    - {delta === null ? ( - - No prior data - - ) : ( - = 0 ? "text-accent-text" : "text-danger-text")}> - {`${delta >= 0 ? "+" : ""}${delta}% `} - vs previous 30 days - - )} - {timestamp && {timestamp}} -
    - ); -} - -type SparklineProps = { - title: string; - values: number[]; -}; - -function Sparkline({ title, values }: SparklineProps) { - const width = 80; - const height = 24; - const points = getSparklinePoints(values, width, height); - - if (!points) return null; - - return ( - - {title} - - - ); -} diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.utils.ts b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.utils.ts deleted file mode 100644 index e0fe37a40..000000000 --- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/statistics.utils.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Percent change of the most recent `window` days vs the `window` days before it. -// Returns null when the prior period had no activity (division by zero / no baseline). -export function computeDelta(series: number[], window: number): number | null { - const recent = series.slice(-window); - const previous = series.slice(-window * 2, -window); - const recentSum = recent.reduce((sum, n) => sum + n, 0); - const previousSum = previous.reduce((sum, n) => sum + n, 0); - if (previousSum === 0) return null; - return Math.round(((recentSum - previousSum) / previousSum) * 100); -} - -// Polyline points for the sparkline, or null for degenerate inputs (fewer than two -// points, or an all-zero series) where there is nothing meaningful to draw. -export function getSparklinePoints(values: number[], width: number, height: number): string | null { - if (values.length < 2 || values.every((n) => n === 0)) return null; - const max = Math.max(...values, 1); - const step = width / (values.length - 1); - return values - .map((value, index) => `${(index * step).toFixed(1)},${(height - (value / max) * height).toFixed(1)}`) - .join(" "); -} diff --git a/packages/ui/src/icons/material-symbols-rounded.json b/packages/ui/src/icons/material-symbols-rounded.json index a889face9..4ee249cc4 100644 --- a/packages/ui/src/icons/material-symbols-rounded.json +++ b/packages/ui/src/icons/material-symbols-rounded.json @@ -24,6 +24,7 @@ "content_cut", "contrast", "dark_mode", + "data_object", "delete", "delete_forever", "description", @@ -67,6 +68,7 @@ "more_vert", "motion_mode", "note_add", + "notes", "open_in_new", "palette", "passkey", @@ -74,6 +76,7 @@ "print", "qr_code_2", "redo", + "refresh", "remove", "restart_alt", "restore_from_trash", diff --git a/packages/ui/src/icons/material-symbols-rounded.woff2 b/packages/ui/src/icons/material-symbols-rounded.woff2 index e8520fababba16cc6be06eac1472b5afc5645086..07edaf7ef299d1c056ebddd858f955bbf9351f6d 100644 GIT binary patch literal 37892 zcmV)DK*7IvPew8T0RR910F(p(5C8xG0aKs=0F$Et0RR9100000000000000000000 z0000Sp(qAmKT}jeRBR_C0El=mL=gxIf$KDZyFCknb^tJgfMfwS0we>1b_5^=g%k%M zTOc`+m;ub3wEj>;Rjr3-Bqy~VGF4SoRaI46Nl*+9uj+}2Xqu*Jnx<)*rfHg{X_}@n zFfp|)Y;0`@7gyWE$JY)B3AHywMA}?>Eoi{=K ze!8DjtYMVlBB~0f@@`V$>k1E#PV);a6DE8BP(A=eksZ|*nBwMiTV98`(~VuGZXJHz z`~0PuPG(39>4&(v86n-dE-i2H|G1x?@%#5v3X~$qDN#KTYHG%zFbXIc%l@xU`;KH; zQGOjIk>%Jm6@aEr_d4BefHVV7w*kNk#4GW{i^TmHFlVz~)lNcGu>|2X&7 z{&8Mm$c|Yk?wVu_p2!g;*;oOg;{z7zcE;Sib)U(HGFVNr^x?RLZ@UhbIF+7hJ^!^Q zQiB3Ia&Uj&Cld}vNCX<7Bw}wnF(mkC%-u}?Cx(e>UBfC3nnwL$fB8=MZ0Ct;bE24F zB}bucX^9i#Y;)Qz!xd@(qMMjtJlqHN&%XZmZRs&IF7NlkP4Inl0+9wGi3to@NJQR3 zg8pY+t^Y!()KLu(Llf$}34QxT53I|VndtT&k#=oxGeTl$pdv$3+YkH8cldBTaE8;; zA*9)fu2#3|miE)Q_kH2+K2_nlJHbeE5|{wj#U;jdXkeXwxbrR4JtMz&IVK%NXMUD&6tTX!~J*`A%T^$Tui~ zs1lkH#dd9ATHED$VqG1~2F8m!*R~!L$fksEVlvy$PJbb!|E~YvKmA)?k_wJ>D{72& z_G*PNEZsYH1P9jT14#6;(=AavgfdyljY!};`?DVhxBf21`FC-16(XEqv@Okv3dOH4MEG@mBK?rQ z+YP@zo7_O9?auEXfTk`sh*fHKg48^)F6kFA@b)8}j0i_58C}d9SeFktQprrl1f;bs z*>M$0P>5@{w77|J`2-_8fJso=t(ggmtU_I6-qo7!OfIMpTv8xJNwBVf3P{x=+Spf} z|8X|zr^O{Q&3SK0NoZ<-PEr!;)eB8*H!;q}G-s;035WpW`fe3`GgXH49vG*Sc-iew zbo!TXS5Toq{`)ca-c-FU&w(l)qFeGtT7&Cb0`G*(aUK8jjeGAtAPp>y6)J5dtYkp1 z^Z)6Ri;*7zPR-Vd4$IuvO+}!h|NEfqVn=;wV9k+C8q6OXHqoc zUuc2JT@jbDp>NuTg>7dS1GR8;jyZ-{gbi>-I6OQdpsHlLvY+ny+zJx)a zY%7-J$hOS)Keyfd{!9^TG}CGpqjzYXP$-4hMU&`HdG!@uA#l$my_x!;7Sf$Kxs&<; zP7CUA0^I|3@Tbl`$#T|NKq?P_x+|}}qQB`kdB@a$L|AgN{;_40MLx9iRlrG=6ww{y10tG|*(4m!>K35s?EDmW1SP4uvLrLZ4dU4%i)kzy<7d+j-}D+Y~<#xxEPm ztjv^Jn;Ca{Xx!dcKy=4Hr6dhCy>~yo4|GX^IKM=aglRW7S7pdFqY`>(J?EiDWdOsp;qIJNcz?z2Vcx{G9iLcQ5f_C0Pohn(DB}a~_ zb*mx4aqT#X<7fz+;~T+Oq_JU9=AG*+DbVl|k|c!=^-8|u<^b8Mc|9=>4!0fO)-Y!qXLh<@T6iva*xd>}f!aJ9cYa{}|Jhh5FlKMUqdVrL<@_szYx4VTdtVUoL?Y7MLnIZ?)I=&& zqUrAc?5bbxs`jvV_8>%H1c&g;H%RVH?;MEG(Prn63W<@r?sfbBCS0*^Ya*3+t*003 zw%sjxtcezldS}jaF8mt<0vVDF0dURx>IFs`O=t$MKBLjF+JD*~^yl6;Z}^~vnBXKf zDei_0AZ>&hl7RnbQkTm1Q;@cwN=R!E`~jT$aZ-ZYJ9g^row{|{g79cRO131U?Ek;1 zW{VMpbqcxY6pO~kMdQvzN#6hG!!vI_XE_6aCPyP40-H#Hq6H8e0ENK-1co4#L6HiM zd1E3#X>DR7DXl}^lsd;MrPvhf(xp3htxxZwt^EI+s$V-7A}Q%da&vS_rza_DWA|gV zAr(Dv-)y7nn-qKrCzpFPDeYr)o=0;Vc9zFN}jr53!gb;E?u4tUy9mZS~O5GaOd9_Uu z5Xmu-rFQ)o#x@~rk50|IguHL|N7nNHJqXTBZ7~@QkELFNp z*`}Iqwo>IPRjXBR-jY@8uDQW2?r@I>JmLv2dCgls@QENGCkEF}R8F))?tiBcLf{ye zz@jRmJXdCwqVx~8rEA5!A}hDW;khiR!V2Hu0hbJkBQa2h5c3gSDij$ah)M+q3x@(P z2w{=O!p9{~9s!xKFbk6gJ|!%w)DV!_PR7B&MP{-9IU=5wcU8y1-E!hpJR5ZlgUcr|TCwTZv>)*pK42_f`2V@n1N03=B)3u}zUUgO%yoDi$U7DSU!M zohzD=+$+LA$X5>sY1O`?B8+mTi`Y*^w^7HWftB=4)b(TW7T*YQ$(R;JQiSOw!%Rf6 zQ)J;(P0y*F80sK6xBhMnmqJR_sWHf5_@>p<;|iF};@#2*l@<$>`i?wR+JjtZQJ{+& zzHi7t+Ifj&s3S2r5=v8{LmrpsgfJC7PL*9lwQ7OOP~4doBhg8U5KoY(qp=Y6%<-sV za2Knv2a#qJw!#4kK+f@qM}jQ$Wp}(YK zXK8y&EnrbafHML04UmmU4A;ysX~ITkhzeaAIFxXm)b3^OugA7_5ns5+DFaq1kulwA zW;squi4H){rO}bZW6FQQz^64yqhOJK;9jAi0|6Z!5C~qtKJ8XP7uC(+KF_>EWG{_C z&<*fVqY9~@w6Kau3Wf8CAuhCZxZ_f^2+9gY5-1#oqvCF)H!Rh_N3$HqJTMyBbqKqs zRZ0%-mQz^Zv$q;c~10%;q>wp0IfI6@x&3(W?J!SL{Q9M`4XFSYlEz%c-Qw*5=g%+v+ z;?x^dS<1v{$H$?|R~q1e1{s3H2od42a7a1G34oEZHaztc1A36*ng>0njc8cFpi3W-WB2t|psHn8l#du+q;FUfJ+b_Ih=j4H`( zr^YaH$Cz8ZK%u-R;*Il`#B4Yu^m%NVxmlu1043fu6$zlC0y^uhX1(U;(#a{5X%J!g z^&-9^Djvv7vTOK37@H~l3$DR6+jJr_m_n39d`v5{RAnqs+}9@(Bo6wEwL4Y3kBU{( zS=;3I;T<2u!pY%*wOCtl7T~}W)e>x^Ve^5ftgmFGy5)KpW$$ZPc&2FFY`!S%{fhKL zSRx+xTOh(6Aqk=M113%4aiZ?|h$P{}>PuBn-m=Fisa{iG9Uw-TP(&|mvYQSfc&%ji zC=($fV39awOkWpYWNQfrKALDXNLV=e1Znup7dx7(6zjP-w`8LMz)@8uEmd2zY;alz zD<=*X;*wz!r!=mPfxsTp=BG;7IM&Um(v53d;R;1q;uk!EBZ}Cod*7i6QBF}RFhJ^@ z5JD8G=z$Cb04lOY@ulpu?LuTc#vU$&77q~lxoVay-9Ll9O^PE{7_RY+Wm?FwxG37> zR}gwK8qXh*u@tgoIq75H{$wA%J@1Xm4|dN>I;90_34KKM^q~_2S;-^f&712MT#kxi6WMp?QPx_?E+- zzihNKh;aP<>nB5q=kM;nGmP|L0c(G@1cD-r9+@p73Q<~rbzOUf$kQoBue+`(q+Zbb z{MF4cL&8yKB1sBK%|kh4c~AM@??14O!UX{)>UZgk1pWC?xD@V$XW>H-#nM!l8d7%} zOH*kk9Y~pxA4O3b4bc*vF%%;)9y`*NbR*r%M)^X1QS=vA#a)TKWd3@nKB^u%zq!8! zzOCRl@cZ~v{007+Ao(u+ZuK7c9`!y;)C+Xr8X`knNDWz`46304+MyfzU;u_;8fM`X zFhGO{c!f`rSd@(lQ7f88^JpDMN%N!^(hr%EOiRxAJigKV+Ig_~Oa9CG8+|xO8Bz6r z*Z)$q32i|;(PeZMJ$bx-{CT2%=X`hEnd}Nb>OZFc{}Dv>^Pl*LD0X-8RJ0W14#$-R zuL{)*vV}iVBEl(8sE0P_e9#b#D}?aN>dUX{+43A8seIPKNc4f^dN>%bh&RRq!;*O1 zuo{RPf#|2evA}16nlv1>3@Qe@Yr1w|0YsVbH-A%jlm9CJ*08U)$#)ze$OkKesXdzR zf^Lle<5l;g{ziYpzucAdSNhAnpWGijR-V@f@0d5V9o06u%;6peaL>9=IX6Zs>F#u| ze|y|>&QH}M-TW2~V*UOLT zMv@>0%6-Ot*@ujO8S#wnWmK74bQZ=%4FLZ{iJ{`MeozKKWw>{M@3B`tn4kU-Kj>YW z>)K?KgYAP9r8hXQ)Ah8-%Hc+qSN)jW~f7eO|Yb=;~8 zwm|_52w>yWt1w!Z{K^0Y1|a_;*)TqXfl1f^_(rXJH)XIM2CE|P5&!=YMwUlM8cV=a zr51wJHMeiQK`I0=!2|+yJOB)dj7Mf$U;+X?CE`*FMWBfXX>2s{3V%vtaTII@1RR}+ zALtQTnkH>B-69r(C7EJflJRML)k6Uiq&k@`triyp6Yp9*?@ zIzgCXcNH>DhC5s#s)9l+D9p@;Frzp=J&dE0kCRGT`Rg>VI;sluxnae$UGFT*vTfV4 zEY6u-ARI}7V}VNQNvY-grW&P?GAUrfNz8MA*Zf41Ok`ri7CJw^>Xyh!M@lDZ9I`mc zQ00q=iUpu+p+)olNHJb4nn};GZ8ARz0d9VKYmRG}B2>}kHRbnvkd4@(Bp%jO)db54}c zN1QX$Y{#>i&-WEd-xrx2E#ruTRsZNZJ}!8WQPs-1ymDn#+l@9`Ij)59x5t(uk9m*K z;{DYXeI?Wv^Ss@3U{RS)R#GMw8TyP8d!?HodgC9!uGGhQ9W`+cQ(m0@Q0Qb}Bynqq2>IDA0fkmAL15&`D*&W#ea0VfO z)-o3{rB^r!UrE{s4vR z#T+K1?1&!~pE(&LlLO4^O2s@3Z0~wJT&H9taE9}(t}^)8$$m28$3ERN8a}qt5jD~> z*oNgeo0E{osr}0sM)(W?c0{<3$6J8v5=(ES2x)0Gp*0Y#XOZZ`hfxm6T?h}F=z>RK zq#7!&0pMOS)u(eIQy*e$%>k0?M8(&T8;YfoN4fnl%vQ(OBHUUL7~>hvvN`B?%Z@D@ z^aw1Djh=`2Dz#DLBq&6c`qla)h=91qP_ew~o7Ju>5# z6o{z9vv}@k-q%91?s}W7uS`jvh}!&oymE~97a0x&7Q$JrH}N+U#kb?F@>2SDO<@2B zBOXsR4(dmf5yHW%@V=+xgW97o_yIcS(zQ&=8%Rb($OqiFk<=nMP;w1Akz=r(Tb2n& z=|n0-BV8r$5VfQZ* z3R%(vNbn+|WmS}itypZxilsR@LHcrE#q|Y!SeEP713{uS{Kplw=3?@VOtUs*xGR;a zljAly17$g^l&m<1Qm8~suhGjX_yV7I8~|v;7*+%|>vbSK?g-c27gTAN??yl^<$JHq zO=Fns8dcwPP|a}28g1Lh!?sZFPF^LF2D}ckZgqGF~Ezg(0q%#rdfC_8Cy1URnNm5(n0=EgP?h3IAWC&xO>QLPi zRh?5wP)Ddp+Hk68WZ8&HU&uzE?yB8q#M>xm_`pnv+Apvg-e;eTd&T<6?cNZzX@!+9 zsCKocad55XRN`31Wl&D*06HYT7NRLst{lajqeI5LHHMWMQWdvqORF9q^(l6%IwE*U zS5!+9Q_svW24h9R6}(a8dPmXCw?_yI$?l#70`xa9LK*&jyG_f=Db~zlcr$8M%#Q z*l~|nW7n9DL`^lLRVZNl%5ZKA33~~Kthmao9B0CgUW4fi`3!9Nmf12f;!U+)V)bWd zI6;eL;yF}i&5`|YfJ`>PtW$hn$aq!)#-VA7t(i)JMUF@54Lz0zke>we8T;w9{m#22 zc3r$<3^o2{xO$0Z<@7-Qklf%oIZ4} zt=a(SfQC;T;E}A>vlg@n;i}1T^?5He)lwzH*q0zM_}?`wI*`HQg-r7(F@OiZVOx&O1XWK^ zt)*ieQGFDV0OrTKYMXAb$h<5Bv15Z2d@5m&0v;jo!NC1DPYN zM=rm_L2^IclXsmwQIvPluigqqXmh=gEWu2h`+tVwH$@Kd}gV2w(`v{&GoaMuI98qnBujv|!PZz`XlLH!a zF?)Vsj+cR&N8nwjHg=%VBV+8b^sdIvn$KVc?jL3JA90Q9V?0XX ze+fc+D8i8b1j1K|rW(#9Do90B&*oii!YdJMuUXTUs9E!&wbnBip~$b^HL_It^-rq0 z(d{-RF-jdN?`Ao`!&T6|0x%n^O&){#sBMhyR?>veWOI&X)Q!Y5al;wj4mW)}EEFuY zfB0oU#2Z^tz3>Z)k3sbb{)R)yO*V77GQaf< z2*3y$J_)8hf$&{%W)ahpY-w7+-UkgmqMk!dC^{*#bWaDpc0oWv3N&%%*&k z5In$`>G$GB*Jvv-9l=`Jo8V^z_m5tiuraeiNB$Z%;XGryHb8%_^#_?DP&SJ#`JI~4 zu#ep2$bO26s*U&AUTmxulMwgnetA%9r!PQXHJ5Wo0w1qxzfxR}cOn7!W&KK2uRPj2 z(r%nlzh_hrw@tGgwZly4dRV?a={^koOpj|HFqN*`IGm*rzwS;j-A8ojTD>1dU#2K-8c zL{A!tW^%~TZqvkiWLw4s5!q&8BiUjK%%nkFN0Z&wPFNj8vS~W|X+~tm@d|}(unroX zT5g20<(?VTpTTBLY(r^2x5KcoQ#xQf$Gt2D9(hk`-Bz94Wfz;}-(w8l5TnaL1q+J) z`}y~xWvwOrvbdz5vhR5gqyUOr5b~?nUpI2zmU4#g;q?2jr}C*IKNO*S2ZC*tPQE>r zznt)bJL?sg&$UCf1TgXBtx2_i4_!b-_NX$R2`i80=A&D3rMG{vCT=Zp_18{ha>RS0 zpX%8i%Ih}znOjgV;j9)I`Nto2Lu6`NR76U04Iz|bY#BUYU{lzEjNwDMt2@^0EZ8`TqP7cfd#a2}It`ek ztBx$zs?4FR+C@und6vwZqZ}Ptjw47O%8Jbr*uyx0Ub3IlcR+{I1~D$L2ew-~k05=p zQLM0}dX%rm{dpc32WK@#evP@>MR z?ZBt%ndU@_=5OOx7 zE>mE5#1F8^IfD?cCloSW^VareWsDk$dCU-g>nMlT*3#`{bQa6~E6<6SIjka2la6v0h z#}Ko?g#QKzNh1pYKqZ3D=Gn*}3lyL(BfFh=hEh91mM@;pN@hXXa)`*IdXX<;rFamO zZ6p{AB%dttVnKAr4QHLgdyz+fm+R~ih?X{RwCDsNWZ^Bop;UGGX3hr{?QD@;~nUs*RhM6{wD6e5D!i=pt_(6 zkC~y7_vF}OS`?RouJh#Zh6AC}`JaKhftrVOkztb)SIuI?RkBEGY7Zlz8A33)H9$Bn zho&INsaRk2MpPb*0!yI>!XOyc>Y=C}LLfx7nqP%{aB6^H3loml@UdWwj$^Za1V{h* zt@}=~f(viMzg3(ob#QG(=kY%4sN*PPVs~$}hS6rv|J}Zye=<(OQ$DGYxVEO`G>nzsDqGl7KUUry=2O=F_chHceNZ&vxT{{lPR z8ZR5;r0kZK?0IvCAkTZ>o8ml}b3G&d$XS-5l2`M`IiqH;hS2X5B#Z0^033i&#+vQe zj^8L`p4B%Gl_J%1d9#@p;pW7U6W8m57uAh!9yn3*DbxYxN2EQ{GklLU+BqyJS;U-> z1&TPi0LIhHgnrgLd|&L< z#r8eG!wbGgnyqpvt@hk1%f5=Dg1!0O0Y0nG;^g5JE>Msyee7&8?`TS=nZSeT-YzXI zG@4^b@4#=yj`E**Ch9Cc;_U^dER-tIc9^qiM&Se znbqm(=6K~L=Y`MuSo`eyf|j@~@aL`Otrp-Wg&yYxP`-yAouLD~>}w~Or;fR|SX4B+ki!D}|Jbd&RTpZrOj| znoc&kzU-(B6?#i1Qra+4<{kCTUJ=Ihl>9{T^mB$Z#Qz5&+V|feVZ*8<$*Xn?(Z~+> zHL@%7z=}>{;lO$cIRDlZ#hw6Xg4>4ox!`LIJ=^Fi9ZOx82E4AUma42+xL{s@Qtutm zOk99H*kEly*Ruv9mM`+%aSCh2&E9_`SXcUSUIPcL3o5#@)f2=7kG z&8`!NWB(3gJ_DzC)j&=;%iBm*4&-C@1?x)j@_8-Jkr<8q5YY-RF{Ec4t_$+;MUH`lUTfIFlGDP82-v1M(9&YgI{;$SZ!Kon z-;`ZGjx?JYs6 zUpycN(0%B;M{O#Tw7nGiWWG&9y9~oR-&S-Fx3|gSQEn z=Axn+>QGZkZ}+F$s$;%NySX0#|1k08wi1vmgt`hwC95rC*n{Ddli?Md%XaNx8)s?4 zal*jJVJS@f`Iw?e*!KXyxLx{g%vkPaIsV>Yb)#l~ee#}^Ypym-o6CA_gpq}$ktCv{ zeD>-Ygb=eZX3Idv#t6rBPM_T+^j|wRS&vJ36P|;j#v9U_bnRw}&q_>E-niO8N@H|I zWml#$T3fFCigk{mVa1_gjyeXE6lBp|z222Yx2)9aJOoJZd5sxBczn`DzJ^x5a$PM0 zZYSHZR8&Rx5?^6JkltIo*|(Mt%drq>kH@^;`7I%M0nR3-+=; zR-)Gil^$JB?p{URC zKs*i!*=$y>dAr=7Y4@nsv-#gj>MMV9e1TX3v`aA?JIgqN8<|nj(L5~Rvv18% zv8zYtrUSq&d@{JLLoR+5Z~o5dkhjfrZ^l~Yk*~ROOLz$Wifkq9{|^u%VlN^40V1xp zk9FWWSE_h$1Vf=v`rbngQM2-*DrSwyp7VRst!e;@Xj!m&#>@9k<|BvLuB?+DgmzP4 zOhpEi0ZWB4pf0rJ-ae0mriC(K$uK4?i2`w@)D!>!0I=9umM8jE)W+k1&pO`1S=MuX zoC$8Hy@uE?=y;%PotVPw1aF4B75jZQ1**3-+Kybfwic`>DtdN0&C87dN9-2A37i5u zojNpD)k7(zbsVg^XKgLy6m(0bGlYXxdc z4I9Ra$V(ubW%&>i^0DsBI9A}@wS2@vifiN!BHP~_KSyt0Z^#+Q>G0+L%bs*X;u#CxA~&)re&dOaLdl3+y*cIQStMKQ%GdIz|T;b8C#w7+Fr z;-hHA-I+ROl*i$`7)`g$74o*kjTPM%YLU0kB?<@x@?4M_a{_gm)Cne-wC;Nn2a3=v zBVx8ZVpT+yAM^JNLQMu=l}PCY;AB+89+N2G68bS%egV#L{)H`g@hy7&5Dx1(6-Q}V zH|r=3t9yB9rIQ5>M$hh4$GHyd6ZgXA6ba#Egc^gP2S{ULG25+1WpShU9o2qI0Sv3$fx@ zQLH#NxWfwt@dD($)3etV*7f-CwL!Z%g=hiVLIC#+pSP&&+op@&r+EA-yFuO0zoBPk zc&DM_McLnU6cKg=aQaUICoj#hqk0ZH7K5)JioxXv00eRb80(Ywt1L`SOu&1U&fWm4 zK#}l9Xk(Y}&+68by|f&`4QI(fj_y3IBh0^h-oDZAViBbvA-(%B6qgTL$+Qd|=cNx=arW&&A81%|?2<8Y3O|Gs^ zu1-#_##c?jd9#X;i}QAd%Rt$Hvp1^{x#97#nJDL9nZM>0vh#&CJB%p}6Er&2XvZ}S z?A*)TbGfTdnB?v7c6uMx{{Idm9xz`O0A>?mS*qWk@evbaiJ?&r%j!RfG&+JPdZS&S zmJMYfXUk#UK%qBCOn#P!W15Lnvp0{e&m&m>qY}Xqc^}n!R=Xk|P+t`qtQKaeLe=VH zroFf0zJ=nV{PnW$@6qDt3;no2)qyBfOMy4wtsz&_GUGqQmpSOY!P5V`T;b0p_dov{=Te3ywp*_+lgVY>4YWl`-@;V8*iY$$Ij3lM z6pM~dZqpAXi`Zt%uI4Lm!S`&!28nURPq*oXqPmKb`GeIW^~zlJ{h>-&Jhhjk4GRab z6P}ZkRkF4b0Y1DjQ9H>EEh))4E4xYVcPUjBsoT`^P#+hcmcir%A#`zp5Oep!@5Zsy zgMSDe%mBBjnA+MHF{gb`>c^3nP0rgnm|u5_r?OA@aN*;!?$yq!0m6je48-#2XOp24 z%oo93&f)epB&dYYoDx%89TSpP6+~oJ59Cu7AH+8`)%VTxu9_)x7FONh0vh1{houTz z@b?G&b^7`5{=n$-u<%Kj5U)wTd==O9v}$BF=P_Y!Zk7qB|711nn%I|+=j`e@#r@+^ zPNi?SJkeKGyLM@&I$->!IS6KBU6L<@eb0#)kImh|WYA$P10GN9n|7ZQYPg#zH_f75A`3oJw?9qFkGW9L~0 zGeBVK0rRp2SvK*hojc^-0x%0wgLHv{$l`q1Ewh2iU^Xzf*op^qD=vvuU>k5bw-VV} zW{@q)TFD1y19Ry*{EubrF&6ho%CVDi79akwl10;xd?5QGDcPLPh0hHp}so_+X12AQ`9^-6V5UB^ya z=|GIRw_8Wq(fjF_F;he&q9*3D_w9HidKYX6yOEPvGmIM#Pc1Y=Au8}rYvM_o@^YVLIEHbR$cETEyXcZ>xjRc=6=g z3Y)5d!Y5yIAYe8Jeld1&1t1(Kjt3j5>w9_W>w9?tKJyBkowrS%WEx|qt$PnTDO0a? zNAT3{xX8@T$O0d2NDQ6A+9aYK&D}FI-Ft@|FpE(S#Ypon= zkA@94{Fa;MFD7E(tZO5M$Fi)Q^@I%q#S~+ENC_Kk_>O(Dzo@W*la93(5i7HH(iJvf zwghY4T0C3x==S1~O99N^sa~1M1iR>s0p5wy6p4a@gpjF3gih{u3m@3_6cQ0fWoGl} zR##lX)HKAzR6%rE^AGS~ zFccR8c0Go0FovzScL6ekU!u@=+|zqoAd525BRQLBMXeV=oSqu}(hih6oXE=s$^{Ok zUF>$ZVV`fn9h|}g%11IHGXF8oe*oZjeg}a4&xlpXG%skkYbnywa&LDU#vcFd)#u82 zAf+5?ad_>oDl4V8>^^+iC~|(=&L)9EkijKb?&=X5)`BQOHPScLt2;Q$o?TotyBac9 zrE*7x(r3;^(R#oKlXxI13NTidA zQXOz!xPSXWgkMDi0|V1_1*Rb1qclnWf;1IJ4J~PJk#N+HeQ+|bN5b6MsYz28yJvkl z1NF#;#a)UeT4<-Kaamgnmi>)j)i@yhlh_E%0poa2STzm^4}jbOo%nJ>W)*ldbPE6- zom5Y4H56qPXUP}T?ktYGTIuv(#+WZdX$u?!M^xU98DptY)4rKFe@BJV-_~~~JQC++ zA(nYbd`AF(xWrLaS%_<6dbn#tQ}C!Nz_OOwAqogK2Eq;k%5YMQk;txs9Oq<}47A<# z&2W>9@(8Y$)?wP-z|ay=Ga1x8-Ms>=EaP^8TiY@ zmcLtD(>(N{M<&>TJurjiEgb?F(3)$L(duOG1d70VNj)$yuwGIRglCd&3>WbVDUBon zB6EpMH7(_Ft;z%;OQ#smk;sTKxU$Z7oCpbEU{Sv?e)zu1oTU7W|EGY^QLOaF2uZBy z&;er5p)2U6q$KqF@A&(V5+Mc1V8p_MNN5&}nz|C&Ki~zq{ajvw{m?O7EJ$DU3 z+8`*%Eqpuj@VUvxGJ*`!ew;a?*mr+sDB+KG9b>`Mwzk0LZAw&noa|8!6oWfP3r;{9oV0fG@ZV(t)n2)8J2( zA98{D!5*=Ocn_2vYJu25AMk{{IOdov!ERGbxI3y`cq9CzDF}~rmAEevO8U}Q)+jYZ zU6KEAn)DsjIOCgNW!?$AY>ak_ljGcq_w___X!w>z3GU>F3R|&(xYO2PC)sy37ILsN zSU#_dQ(hWsy@~2xUxcR9AFmw>lm?Ei+4}9FH@mZ8wf)ku;`nqbj3bS!Tu_&XiO#j- zCb}!#ryd)#J+H6%g)hqD+TY~A57=3yT6JYsgDC3|>;CLrh?mQ;*$vBVAEQkraH*+` zk2^blCEk+*DFc@R*PBebdwKRh@9EiD&=rn~%O!Z3RQ{}b`ft|P8|lF|!S}7fPDV<4VGvh z;^j+>O{D+qR#uo4B#3_qabtyxfD{V(2SJGc8-T2l2tfRzgd@&2ubglR>-YzdKtTTA zx2wAXi*!(d6sT|o2&Z@X$Oq~3DKJ>B0e2V6x`nIAW3CMjiUP4hq2L%y0-jE#QYmB~ zug}kV7!7)1*c&{5{(LtKPa!y4y|3A3^OEHXPKl;V27bE1mZuKOd!8~e+gnGykQlQ$ zn$E-CLZc{(np9R{{ZJaWzBT}z|cQN zsyJ(_8)^h$WA$dyR${LWyRyxC^$*IU!_Yvi#RuA~D&P1mo6!=0BecZS31Y&M@$uKIC-%@wE`sZ_Yv z06<+Os5Pk6t`|QqLJI{wD_##wu6@08I3y~>b2N^1q`)`)$4X3UV@>^TcpLm>;mLQA z`P1J|BDfiDSgVsgl=MSuce0yky8uQ&xxWQ9PmK$Grh1qb(NG6(ck#84{i_Sl2()W9 zn#an)4$aYQhW2M&a8ICB)$Qhaz&i!!c>#`N?bdkQYxZJ%aQfbOtXi!Clt{y=o*4H2 zSwWjB1l13|YN{U+jYcU45ETKy(-d#gw`Ol_O>3Bovy#BGA^@Jtic%S)YnZPITipcxz7sS*f*0X=F@@K`q?4-v ze_V*lfz>RVt0;sqg7$stm`6c`d4q8-`oct_JQxfHY0sRq_Ok7r8Ex$9bRJy3eEISV&U>R5&}rEkdT`Ej z-~>D-B{EoDPV9`b^M#A#CueQG?k+g-1v!on+3@yJPv5z-+T&a zCawD!^R{=Z5pQx^^KEax2b6s;xAWlg$*tsI<@zHq@O1A&pr+{kRn}ZBXSM2)mMH_?#hZe*kK~*hq&fiQ!2Cw* z681Kr)FV0?Ew&lv^;&F@vv1YOt-c4FKi0o9z9*F0mYTW^_PknnddOHr>tyA}v5% z3l?7piwC|qD}3>ZpIs!EsRVa=vW?OC<+o_u5z zqm@NyhUf4cilO*Z^Yym->Fu*;&z`;g)PFz-p~JdMZf}H1n1m^qC;p)C@NaJa4xiTM zVQRDJrkup84dUo5OTFaar7uCy`=UZDFD@)UWm5r^fYx7ZYUSHwsTh*^_y+SKjYWb-PBM)&(3mnE=Uvl0q z7YLh=cM`}JCq7GyM@?F1&S}zQ3~HFd*H&2;wt1|h8boWGhVGgaa~?x?Gb|p}_KTD3 zY}+Y9-X*u);zhyC<)P%Z8zfe|(!J#!to}Dlvacw4Clu; z?#4NO&fG3K#tqxrg9?pa-*ks}kPErauXlThFX)xu_j-i21z2ci%ptk6DIMy5v%Z-I zwf@4xnPqbA8 zM@@__FpcP)RB!mSv*g^P=Q#5Hc6P-U*+GMZ@b8?k9I4B?TNQDFcU*GjRBB z7*?P#tRDcV2-7{BD=(hbX5!36@p2}}n2=!HFfG$NRKfvR2Pu$di=ykjP@Nyid3t0? z3=ekmIOzF5w3qIO#l;0Nn!JVX;`77w|MFQMso23m+#$euR$(ZFSb}ho^MjRbDYK_J z1^-r!mls6Vu%L|KTo?mo)4=N+*qmdG&kbH*BSqQD^#eeSF&?Y?eYf%UMdizc5b5TO zDBZonruGh)uupSJ2;=26B4b8J26C>eAvIDWJzU$N55w8*wZl}Xr|gz5n(=ZnipT{B z+!(2618NJ5flq)jaXqTPDWxhXTb8&UF)+6fS{6qEe?}?L0kxB-`!711JS{+qKlH%s zO?Gq^=hxd@jn1Mu67)j_WI%>3+7I{s|K}&j?$z_eL+HK%;*yN?uHm7c9qXb{CN)&U z^K6Mhij8XHqnH8BIoI@jzVvq17Tg&rSJ{ExHs)au1Hd(di;P>6!*bn>^NJS_j^ObEtIn=CV) zvm#svFcAh3@_(f?rx9XzP5>$8Trh2`{c>{^XNNUavxJcV+Bg4)7WliETI zPe|-{_;U=DQWjjs81@l_5nTV~=#y2E^L@95@!bW@86|{J#&ryE1AXcEy_0Vp-%j=> z-N7E{+*764IJ?jd3`|1?dK$NU>Q2#cvi+_uz3Sr2WZge_6L3Jwi*N}$v0q7XvB>E2 zA5Ch?_^P81F4#yH)0bzV$0dY40>Popr1a~6N9n!QjTKyQ{Z+lhu4$rpyn3D-mdj*v1)@x> z^=w~4>`OvQeK@j|gL>+e_=U=~YMwJ*#7XcZ$k~suvtQL^tu-YOgSHx$&Qp-sVM5oX z1fH-9`$;5=Fn%=4S9VX)KY?6o=C^-bHud54^^^dtY|5WsM5)1|Y#06R47R$Evcd#!@ZO~LmRFCS z|4ZgjLXpm=-KB|$n$TLSu>ml+cGm$LB_q>S&g4GYY7KN?Wh2Ts3+0uz05q5j9GXqU zqLCOI#<3hza5046cI9cD?&B zG8G|cYtez233~$(iP!x1h@-lvEJMWoO`axkF4yWjpNf!C7>4~>|D@asK8g&Ir6`Ju zy_f5VZg(_Y9FM~YzZ>DckCBY1)}d670hc;Sv`ln}TI^o~ATPE?1_}X6>ORKqg1eej zNPlv#$Wu0u0J@s!&V=>d>w2Vd4$s09+x0xOT>$cROj#*MtC4-6@pQJ7NXCoCx~*@+Q_lg_yc3h--AnmKbTQ#>9`P1`;}FE9_s3QV~Y5=afj}Skb>< z_;SXAev9>&Ghe?&dTHiY&Oh{ra#^FN+7vb>ORdq$*w_pL0JbWXQYG{%vYha3N-69F z8T{u<4<%@g(Z-lK3~{)mZFho3^TFP$&a1uNa6FI8#)h&uS%`s`yxue+b*Li| zQpx{Nm$3Jxzf9XdMQQ84Zjp7WiRn=IUo2K}mP?}yWLdnL7uMfUsEKKD=~VF{(d-hRf7M!;BE zf3JTt21pxDJMmb+@qn!ctS5;1+IU2VsobauX!{CMvvml!A|-@U3b4o_n5gT~t-c|q zih(e+M!#w)2b4mGQZ(W^Zniku;t_Uu^uM41@M{{R4;`KjxS)N%l-26iYQ9p{>UuUW zdh2X40OL3SoC77TWSGSXSE;zgj_aU};{oUP!7*On0)IP=**e(GW}zq7BAaQ#tG%If z-x8_?1)!oV6T!JirXuD8TSp*`k$~jnL7A|e1btK+N$3AXUeq;T+ln)%JF8Zy;AB0t z7hu!j(F3NwzdJKfEbnald-<%Mtt^{`BZy4UM|SmkTkalxYORUQ?GvRox&b1r4-C z_b;?1aTErATVXkDbtlBdDS9!K%8z1$(Sr!-Xf-^*~hbOranbopsP=j zHf~vMe}Ubhf45@HKnk5zEKo(Ce={|^==1*6?B~hA^BUBo+s}u^&k_)T`4)5lgpC#! z_69Am4~P@!7x*3getu~FfL}qu72v8o2zm%W1z-Vs&uP4>us)#-O|3>nN>qwObd-)r z^NNUpU@vkyrVCejV;fcBE8a>l#1~P)5oWiypmhfbYPpDXovypU&lWXhGLdki*$iq* z?HSP6;4mmPq?B2vzLioMC+lxjtLwJEd=2MUq1~5t3Yd9mDW~3GM#-tjC$V5Lb=P2F zCD^b-hurGY6fT<~EMk-6SF)H-3)fmY*soB`l5=!WX-8nt~^ zp2<;`DF_^!QOay5;Kq`s^iay}uMlqc=%3%IiOn%bWebYCl}sduLi+T8U;;%xD>i}8 z>?GgAaq*e$T3w$yN@?B?XTr%-o)r!*-_IV;@^jRurT9-WUA`*SFBOgt_Lz#xZ>kq5 zb4BNKs&YB`rux+(Ky?ST8gvs22x*)b{9@x80!zQkL{>n)mxL{#3()YasT;2 zX?`gJFj@aX9wp`g1XlUtk+{9sUI21&JRZcOcy@lhceD5D5u%w<&Ks?VNZkUf4}WH+ zE(MryU9OM|QQe&QKB05loVCrfTQ!a-2Yr6?P zZzuj_G#VR*URlZ%tQJ)}-xjT(LZ~T!C(XcgLIq}0fA65Czxlg*!Z^Rr*Z+p;VD2PC zpJ)#8-82$=FM|JHv@Tu`d!LpsqS>Do_v-qRaI01}%;l#P1W&^Pe|(pPUte3JKnHPn z@L3lAirV{pem%5k=uG%JEY$aXUh%cG8H~4opvvwHV`~?cZ@*YGE4;8!;r}w%=a=4&aTZFhNqe=M}PGz2A{WQ3K zW<0bv|3d7?cYnd(D8mCjhx7K(+K53szvJV;6}q1AK66c{gIOMK+c}#&1RAjLoc;u@ zBUCvIcJ>|xH9AgDXI4q_G2|pw^Ay;jJRan$8qT*MtgE%sQW***DSivY_5f$|dCUb# z{s8uxgV=lYux+aH(HUF~mtUQrM%Q5P;#JM1AyqC;yyUWbg6}39xhU52suer}7Wn$k zVtC{UbQfJrSI>94kIv`vk!CD#%9Ai)17QLZl#qyJ5Ac|5TZ;oPh)DH@ z-nBQa&Ji|ybvAXRGx1&9{;|spI}?bwGdT$6Cc0Hi-Xg$?a6QlSsOx&3r4|g!nq$q9 zv6wL_@$p0LPG5D7;u;sXtBO-QNzOTETJR5bjA1Wr(<)}gWic?IxvqZ(w3{pC{1-t@ z89?TmINM;a#RX$?-W{n&V~fJ1OZRrPJQK%KeBi;qAL>2=d~ZKd;yDlYKj$XW?{oKCOjHdzosQ-CI zzq&|TWkh}vLb6R~WKq;oF_vBxWmWFKD%Hkz60u$)kF+h|P})K`jqY?~E$7vzK^D^23#o@%HeQxmC}Ekn*bPx@zf7aBM;J zc*jMYM9U0EO16x_<$F@b zVYCJ#VG@S5M-*f28tI*aSnD34;k>SMscEQIQc6L;M_#8% zDOIa!B)h+|s(TE zBU_E}M$@|GVb8E+5u?3E6L)$9qE4%uoNHg!)c29SU8;(=HR`~P#p7#D7x?GLdumNo z_~gs+bNOaU$FgtkTq*`Tvx?=?kiu*FbF2l;twv;yqx-+s!R44qf+@gM`Sjun^UO># z4dl8qtsCcsq3;Kgd^gH^_ZJP+S&|l$i5n=Z=IfjuGI#5#hn<+WyWeO;5giH*shn%c zE$QfH_eX55{0N_ENNGc9&vq;T>9)EB%Hs(`DlViIOYF_6mJy_}IUv?~7_3V82`2XfZXv_5Ws%_DwLOdw<%7J`IhVs-# zpU;)BU`7w82 z**F8ghS%kDL+=ZZ*p4j+UPm@&(8OzSJzN^vkTf}o)A*%JPidGY1GPf(l%>H~j~z(j zZaJ90yxoQG>1@E{kbG1xC!-Xhp|&hh5imwbFfK$1mWeJ99hCqFhN=KA#ZaH52Bw*p zU$n@bv0;)Z^IW-HD$cONYOP5YR+5lq8sSBFC3D0{@ewegg@~PtS{sdDNpxHv7lP|S zjW`zTrCF9Mpg+4;9S3JjNPG_VmjDpsB1l$pWE>t&tuK$5SajV>o55$m;fBE%oqA#A zD+f@Qgiz5RLew(9$;8U4DO8g6m8xuINl~3E)UQl|XeqS*8l$OE_7Lyd4mdOrAVjv*qOmLti(--znmzQF>{6t;<7MvNLah^z z22q6z*1|A+375mk=Ecs6i~^EqhKoW%**OY0a{VfRwZ{lJOPUw$BCFQP3SPD zK8-U{7QImL&J!KBtE=IEfB$xJeo=!Ja;GrwIsUk)#cN?T=Zf`4lAKk(qWY#;GT(6= zpD9yHo+cg6R>~#(pW3AnJ2puvh9X7T<*0FQQO#qP9xKZcJdg5&G5RJ5a%LMGGvqu? zI}8m_%-6^eLXcK(Ci!!3l%P7P5Lz2*=nmwWL@N-jYU@l$BLT%DsT(VpqUQ@ukgvpI z3{{eY(xy>vFy^vSHbyEQLzMLc$$b?h(Gqcv;H~0wH2UWC-!OrA!gK~>?Ki%H48F+F zM;oR6&A44Dfl;AYY<`^qJc>PK#ZeGoP)K~=82`Svrj@z*=89gMDjvsN;o4IcEs8D; zs9n54vtmhSFp@CZ?cjT?=l22FCH_YrPk85_f=xXwq*rc3Gwp#U4JW{gX^qA6dG%Od zTMFq(aH%{!Z|M7Y>eMTW2PqB_cV8N8X`fB;dD=*CIC|!~=8{BQCYL0l(ivkV{6Wcb zao|v~ok{T(WxojQmBoE--nFVo|w)u&3FT zUX_F-hTuB>LPS}*6NdD*`D_7QmPex%Y28LjLqx8KUcRIoy!OfNh5p9Dg*^(1mAYs} ze$nXG+ezgA-Zza~NZ_Sfg)J6=Nft&s%FLnIf5ee@DiZyeeIAD`q_oUTVu>w++sl(s*3Da5uXx6EWq+%p^bH zL5hk5yOdz=yV&F}Iah!Q`Vp;{F(07o#_hvZwvs}&?nCuH>uH{KNBK*pz2dPZ6qVX^`$v?BK!y36(ho zb#dCCaxVQ3!cRZ_hP&VD--oJwPx@t&A1*AnjOzqlSoIIZHS|P7t&~t|!4oXfDkT)! z<8;vqa>du;lyD7Pp?mpGy_fDi;rICut4vqvdg#yG>V+Tt>VH)#(->OwLqqirMfKNt z9?v7~Wr*U`Pi5`4Ff(6kagAd0bk%C;4OUN^ubyQTw!jSjJGhlz?nK2#1xXcDFTkAC zKL5`Ba!akmiQNkS^m*czdh{0LSw_LqFWn0~Quzk@`a<+e_wpa_-M@RA5X}hv{OedMVd*|lO zJNKWLOH;NCR2}5T_QZo}l~Y)OY-nzr9K~3} zaQQe0wyWc-1C?n3$-6P>^{KBDwLXnms`K@#=g3tCZ~l`3+7X*+n%L_48LlG?%yOy> zp-!@w0R=`tK$XEef1R&E6i6W=_nAj0$M=2DH82_?ifh7!e!oSHd6&Y2T_;vsU#f)2 zgk^B4G+0mBpZjaW!cf4|viiG%4QD7|E3WkS!@Kn%8EnEYtU3ZkS@Ac)v3@{eNp=;z z;rOMu!-U{z6k37{QUBEJE&|Io7SGU;7=pe>bzz`Jbx{yhgd9Q0g@ONkh9S6+9W~I^ zvzx&0W-qNEhSFSu7X3qwt#~-_MIF(8zd^bm~p18sD zuHSN|M&6rcYr+|$Ru7E&;XFw?ZW@I<(O&XkX2Ln6%QO_M^VmLmmqGz`HJWnl~sHmL*10xxu*9oLl0$HH8egE7wa4PLqu0 zQ~tBiD3>T{bAq&hyO#4s5fiGIFFA|s5TWT4X21*<4u2MI6gfY>&#|JMZUw_kF#}5A zXiA-zQ-}X3!4=7+a!ZOTv(hO1f?9Ap;7ZA=9I2JGa!k5D{YG6^L)||b%St$7!?Yt) zE~T5SEQuJjREol!&=LXTv=q6+KTKewl2mcfA@)59zzdZbIo?rg@0*KIcqaU%z z35cQyW`cH*D(^oEuB9*u6^=GZn4dGEV&rXw^h2Sw>u?*~#+K&jg>^Q^u9zD3fU-_Jv)vNN(J!<_a4GQu5aD=iAgMIi(z7&`Sk^lquV$ zNa~oXgNQU`heQw+r4&s2{xFDVd-CpOHV(s>X^c~SmF6iU4NC7D22uJ|#wto{t(0zN z_00plkTiRg8lyr3`B8i4>{9S)~jDK>r)%p`57^D?VUIA!nJNPFLa|z8;X{wa{Lio1J|+ zn=E!#YRnkbOmrF|#Y3wHdYg}Gy+jP_a0w!n+eGSnV1$U&T|BgWJs|t4xR0lyy~l_p zbriSCv3vv_w58PkYdMP{`(!SFHk^o75JeP4R(3NmZ^J9p-p_y`DRG9>0Bqz2vG7)c zt;@Y-rwhp|s1(0XVKB_%HQAC=nUH$0>BP8SK?U9R+#X6X!69`k^pORb4dT0HP{4Sc zB!$jlTBEr*+Akol?8Uy(z!o}hn{$Y}608Hoo*RmD{goIcDaC$*(h}ksU~+T%^wkAV z?Tw>+@$lipyI*;Jdfa!99y@XJ)QuZA8@O@>+@3O_G*O*xp4W&=(Iu|>hSs6HB#9M+ z33oM5n??}GZWCK9p^K0TtLnzj2*wTB^$u`rjSHPi`#K$VZQEI0i-D4wN0Q9d&~qR{ zOuO^=v8aQoo&M_=u7Wsy`SJ~P#8W3v96NesYwIhoyc2k_%ovRB97>&%CZu@`_Gjw4 z)K3Me8Y1;qcb+3gh6&YKoA4HqZw>-q6nL&}@GSWic$88?bdc%WWmT-BSt%Vv(>M!& zbV#5@NuDM!m#Ltp04ZEJib{Qzm>*r1&X7{&w5lo!lC)~|^(epA3}!IxNK6uPdld|= z$f^zcMPJ1I6}?U1*d21NK?U;+%glP0WJ)yZCB6eqGkDPaJPM$*37iA1WON#ZCabsA z9Fc{lBJB;zm0=pDQCc$ScS=ge4Y7~g1d`3AK^|4+=sfkA=Sc}kGT`U# zzen-f8+rvsW>!4092<~K5pbCeXte4nm0QWO;|5_~NS%z0QBsZdfKkSGf&6J^f#k}Hfkrx~k zkY1Q;p~1rXR-_?2ib_nD6@)BxaG+O|t7@{Cu`bhyJUG9GYYWS^OyrsAgn30SXX>Fs z00;(dTe-AwJLjgOD4*Ac!M|69?NNA!bJcBCdn9E-6~eZxR*H1Mq<(#eg29Ssvg%Wy zZe|OXBIPu6g(LUopm|GAESL!u6r?36Jtsevnm*E z`1E+1CXvMVu9!+=oaR|d8J(y>qJpV}WiJ%PNt^}osGt-$64oC{D-~Le4!W2sD*To$ zYXlGWz>`YOB@mm*8QL@8fR=oZD2N9Ze3c|&>@CYhVUlcJu2)$8&%2A`;|uf4GxVrv z#+^VJsru4VS`auRP171Mj1a;T*4QyTV*@(}-!LthF_*zY>kJ~RwN$VP7)Nne3o1&}k(bu?_V&iQiLaadf4%h*$JCRUaMzJ1rLq5;6Fm+Iam(M9Ul8~dv$SfSs5Hl zD2ZSMo{}ra(Z1>p1jU%Z!MTB6hA|z`OPh*8aBu{arQN0NKt%R+LNsGw6KvIz$^$KN zdMXl?aCp9I2|@x^x$mT|+bk7hs`ODjFclK3(MSmW2y-3`D50DvE-;}7i*C%} z$-H7={!I=l8mwfv+gx9spI=_PF?q30-kk%Ah;#QeoX>qFFIDQm)$oC%aKySZe<6yZ zACWDKM2~iu zj&d;v;f#@vkrbIA>Bve3W2~ijcr;~v$=>I?uMRPJO`eypKTCFas zj$efdj0>;7C<+sQhO0bneOkCUNuH%?mV1yf#f)%Fwzn4qSKn}70|5Lo%fjG{qu1JX zSg+gH=~QwXx7u>?WVZri=0MSvvc(JA*qOsNV2*}TOl-ZfEaOjK%OH7*0Ln#_V(-tp z^YgoB;|g{Y%rKVJ%S-ds6UNx+)I#J6sfH4#n_4s?%>(vKrD!DD;P|^(NGZ*MD2e=k z>i75c&Q%+sD3_*hTOy1bYPB}t*b_Vgm`0a#M7!)t5Z{Z zzcwRAi~gs}t7=B6XIB|v?SQ7aEKG;=o2FV)qo`@v$Ww1GwgC*kzMThspCTNnDQGHT zT}3g52xs9mUtu&PXxGr>ah!`j@v^IQgk}&mX-q`O#S3j$3o3G%7vdFa+Ohk=Fgbgx|;k+?@ZZ&?uw7>pb@&JUxaa2O zNx|L6=9Egf>}3ae?uQf{YJQW~$L)HR5NuZD|4Ex&Bu;c;!FBiwAK(RCatz<^4L&er zse-o-^1e;={}@Vs9YahorPU%p*uTy~Aj7%QC}YAT7J>g&040_g=AjYr5qO4*l@g^y zDTaQ)5!%qu*z|2T+<--p+{;o zejAHV;Oj!n*J^9Ug{fe=Eo2q82)JUl!+K0bxe#``&4*L8h* zdOlTTk5KnHCd2KvwQS2YEjvk45>@~CI8>#WDO$)4hh4|AR>M!yJ&iYn188B_^Sr9C ztM$>*X;+PeOi`}O!+6|KDyGDbq87~nbxfsBuf|*>L)2VUZgpEmQ5#NR&RCR$hkWuL1FL2^_9(;to8SD8-vy=KA7wfd}CD3^D3xbA4=y6yHmJ7zO7va>xeI~(|k1Wn6`g+q$N%6(C$AFdqH7G$%Cmh5Ml!`B|}q+$1Y zxedw6o{WAo333!di$v&$og(&*ILU0jG)Oimw^QGs5wBL1`wUR1{kF|`gOAMeqj8PNVt2Lk_ zYA(14`wudsmm({-`toXp+(e%-k|{xU?nsQ;kfZMrkq?`_wm?kRWiAC8k_h|hCnRQK zK7jx1A6u_pKK#G&GwlcNnR$HhKWu(~{J{Nd!fS*@hG|gt45x8cW^a*JMw&}LHtO1wu{|Z2vqc$a z`@LF(41G1AcAt{ao52gSdATSumn!Y=&35DI-bSAk^O(BYVJ3wY3ga1(5?1KHNqwl# zWggS_N%C5yq6vMyxvMEeUcMkR*pPRYK3SFnS4?gv&-U5|7S{X@xG39}Rg} z*H-{20YVD;`y$9FM8Y_Ivw~xnUoz}xLEw{JX@jxAI~9-PUtz${0lr_!CmSBN?SF%6 zfe`dl&r3Z{*r1Afy%}csRl+Si3{UBPHlWv@7tgsO!7K26pLI1LF!cwfys|j2tQi&z z8`!inW(b7DnQ<`+R`9In&4v}O)rQYn8GnSZ_*b!KUzATD1@s!zX#UbHB=8bmLK22B zJjw7A`*Zl&SUILJ{Fwc+ai8IVbK_RT8)*~8%u?E=Wt22cyDI68Ffc;$W2h7q4a%Tu zP*htT)PWwarpj12_ZPkjZ*8<$TLg`p;@zO zEtx5N0%O(J4N)?wO&(X5Ki%IV+mI4^Q-iwI)?4}mQQ_aX=Z7_YhuB$ zc!>W+Se&v}f6l0|O)oRI5VkK9*O}8KH565I*|3GENEoiGj_Vu9Il%?rFNB2i5qp{1 zlT#;T{uNNtHRKO`gqSbprMZr6S+?W4UJ&3gB7`wQ0updyVro*y8K)>xVrJ3?M-~QzJVm>9 zEDa#-yE+L^6?nQ!9+#I7JLTom>WBfq{sU^WkvYOK65+scjFqPbSB!L8)t|gDNaDyPN!jd9u8y52KF}oJM=tG>GjO_^Unse8&4X*jq7bJ^U=hrm zkx>1XlF@3s9)Xvjzvs;;(b}!=XN2hZ|KR2nv;8N%Ur4HU`AUkhkbUOknK?b?SCp20_26mxVI>mB zoRMBUpNp$gbj8iBC+QM2UDp3}@#20RVzzu=T-) za9WVavM>xk;mf)3RNUn=)3h9?5MOQ6G;Q~ciII#rd4d>;?8XWPGnh`xqyFCo`+edFlIT{@_F$&Pje^p3j94HL`W{LH$V79d|x{A zCxJmDkjsgPs2QBVgP!_oOZ&xuqohUuIPcdDU{r(2_cDfMyP?30EY=7KN9F+2>twAl z-EX=<)UBMqrd-J8$LS4b>Ge^_@ticene4YqpMwy87fn@KL1%( zesin2x%nuap|vClP5c+%#ka3@qmbBzsS@xBb1o54KC#G)!Jlsy(+NYGvW1A3N=%u$ z0z^w`eep5!UD#H5HFNXfvReU z1jfEv=7Uq>nBj(OB*wg0Ls6Q5ALh7%*z>*@*A18uOe75ZQUnwN3X%mnCf5S4y8}>E zn7mTiuqRH@&>w@U*VPEvq9IJUG8i=o|3kwK*oz^w>VhI$9LQin& z5j)^%Ktl~?RK)#u%5z?h*+{~|G#EbHWBIOyaezOA;DvYvT$^p(&e$t!HfwcDER{7= z;-g)~w4#|D^6hJHk{6M3u;mI0+<`~%5DvrPQS&EhNHXzd{CxB%Ryugrx~T zG?qR;Gal=44A=NKOU`#X4SE;60{M=j^pNc6G#ufli_GQF569l$|iC5H$;t9 zB}KfNRVBj=SVXrB75sb@-s>3rMrn;L#AmQo6ed|kv>*I=v}v16rIIn1vMSP%aGIQ1 zS%YfU-6t?;%)wkd7_*Y`8n55>@@;ndUEx0wALg-2H1<7Fr=?qV0XP0Fgb^XsWa@-A z;vpn=ZZL7+fSfKesf?#ZHTL>=+?9NKK(vrjC;=^*AN6POf-`s=0p~NP;q+*Us|mrh zASwyhI;n;si)2C4{q4k<8V%k4)6LO$Mr~n?N$y!5OVY2;J3m3&AUI2u9ZDGE0DWg9 z4JJ(Q-0u%@0b1u)l738*BvIZ>&ns+}&%{OJXPP8Qn&)}RKzMEu5Y~=M8Dp01x~?HE z8vm$JMk!_d0Q~#B69b2uheE~+>eQ%$MDI$YiYop1o*h~pzg6^TBhj?RMT4PK2K!eI zNVFQ;RYW+-=?E6;Yh=QqAB-IJVj5Qb5A{cLb7)rFzeB+u_D8UwZD*khd+XKB+r+5i z#dxI>;)?j$mtjfCdG#B2A6G0az8H_=+v=gNcB|IyGMTjNB=xN1D!bP@8U)mJtkB39 zXRJDwBoAYH2PXkp;P=P-gObmmiD9;Bn@reNLZ*5$=@J}G9^t32S=*#`T0;3&cg0JV zq>jdqrJP1gl;V!{p9d~xt{2KU;;{({`$imVCU+sMb=N>*jiD6_^w zCg5oKY*$LjE3Va(^uHPE*_rz>(F;rds$(Jp)F3DLBdv~HnnC`mo_9LepB3gnSyGWY7`!Pk zt+vop(+6_U=NjVQ1D$YvHlR-7I?eSEl;HG5SjoLRFcDTj3T8rg`yH4#nDC#^8#kwg zuTLI6e0V~g{sJFB1lyniTv`8V+?Khl4-%s~O<)^D_M^SV6AnH&eDd5loCYU)W{k!; zH^4aGSTeCGN?2xnH5TllDhqBz|C?6m-?^QJ<2V{bwEz@-V$Q(dLH`)*(J(Ff=$pt= za7tyu*tQv&s<8m+A>!KY9!de2ra$_VvLaPgKey=@XG|L-d8R?9O<^7xTbnyvjrG2{ zGqnrffIt>GOhc04vVLykS6LP~>)%TO#w|r?c1r`uY}RUY%JX8ctzjmM!i$eX>i)oG#N9h52ta ztwMeMj?CAA5?6)1J%p#3>9*l({vro0eIyR0=4$*2N{MtwUVY4z&pKGX(<^X{z z06{g=A`DNKc|zX2wDrxl$)vpg_Y?9$Q8xVOjF#(@(8VD#3L#y?HQHQv*g`S z0?b58pF);fa&U&T#2F=&GVR&AS~E%r&2Sm2s$yG0iG`Yi=#DL8F1CL=I4SZeDQDg^ zo@7^)r)u<%R_?_%J3mm7Dx_s}RlP6}^afs+#^SC(SCxID=_Hzkcv0v{8Z!eC&8Mzq zRGRI>1wEeum8>>{9PvOcguP-ze`Z;(jF@q548}% zVCa45G8z9tB6D&7#*G^{?mz92*#;d>CtwSX!MN5*<+K!|jNtlke zS9fP?1f&)@VgQ#Ul6kgCZT!}Y;msi7djW1X4V3LR8idB|Y@OHsgFUsoR()BdJkG%2AT0DXYW}QWYx#_0>juN}wnS zs=}V(x1bk45&jFw&h@p%v0e};K z?DcQ}FZL0+V<3*2<*Q(i)o>9khQ$ZWWcCiYnVK5S+*S@2Usdq^yNegUgD%72u&`MH zh7R&wAN_hE==*n$Z8U{8QFRE*Qh`h0MkNQ}MjY3cr=cxa3SnG#2hE8Z3{`m=`LeI5 znwqVper^Y+GDNvmC=1U3Vc*T^pO+*o(UXNWW`xv1q= z%Qyi>1Je~4rE5u5A_;@5()?X=t%L{SC2*O|Y=*%F&1VBlTX~FP-^BMDrh+{qcKXHj zr>+c8(YFYe0NH0-jA zw_X3J?<{3o&wo-`w$Z61+N>+AL?(e;rE=9t1+LH|m91>fRt693sCeuA9%70J1^W_| zNAA2RvEhOo{Wa5ec6mJiT@6d&8N(y7KYO`6Hl7oELN55VOfS9u>Q^Wup zeA@jfZ~7mT(68`gLf!3utBhT%pG1E>$~srYXfRH~$psSafOetiWRQxa!gu%;>$By@ z7oZXksLYm1Kv20d3^rY*+L4nzi2g*n=Zsr+A9!>|Ejom|=VZ@Ggw{G4S9~X(n3|fZ zM6dQiZc48Ah0*Z=1g8#^WHVE@*Qdpi$yun+-nwOR9GWR7s|M*`kv%1k(va7Uk8{Qi z?3268qdE=)P;0$yTH~oS!pYp`tR^3k(cX=Z2N>UARu0!Zj^ks0bS{o_t^3Zk0)^=0 zvEi?@vMi<49Ix%|&NKjxtWQ__9Fmt(nK!EQWxO1Am0CA@lru*%$i%79%kwWHcYX z^8c;E)&ewSRX;^NRE=>Q`No*q2EUOqzs^J{R4q#qLbo5n+>!F|FbfeoN-b{%X1Q#t z8{DfQ+-1NhqbyKc!Ix6g?Z^iwXJHQE04xB)BT=se*K9mWG$b$Q%Up=@)zv9gHVyL% zV2?poTHr@E>}HxgddZT@kqiexC6!RGDt_E5&$$CIhaWA(t`x$wyDtCYAv}?gHJhE1(c~nVPn96FSTLwd#D(OT=CN9@CC1G>1Sjg2GL zR}qaXp;e{)5wer8-*Tq5{ll`gJkxy|T!fOiD$@&U_q$zo^Y>3f5TA##>6)Qbc%>wC zVD0SWl3pL~o)rK&60=VZ;}x;6m{ph#uK|vgLnyz}0Iwk)2CwZ5k9Uw-yzjr`73-K761c&3Ln_6FcpPM*)tJNJU|CAPN@ z9fJw97y9}Vtbr%=ExkDUap(H=I}b07fmh%OSS!vz*wtE`T&1Py9=t}}`2(h=x-pl_ zMQ(!|5Q7yNrvCr`&q((P-@9_22lr0L!SeiVc*{&4J$n9jGM3#Fm3trpXW=qjfs1ek z4nbex3f!1{KCi$8AD`QhvSJ#oI zsRgF~lVNyEz~KRdyPShPBqVM#!lefe^~ti0nQ) z<{|gcD5CRcXGv4Vrlc6;#WZ7v7$!kOW2bARwt4}}{PwydQX2Q%b%1cLJ4D$e?R12# zu(8)DuS;RtASKe8o(#M*~W$H@VJGxS_>Y9QMCw8a(_zbo^Gb!7*q0f35qAnURjFBd2n!3_T zwQbWj);b@Lo5+AfR5B+{W2>w9++T}97DT~N2pYM}a%i!uE^1k05Y(+;IRI56aAlS7 zG8mI(8Ydy3!$bmvZyI{q51N<8hs3)Do)K`F85jevxI-%EiGnAUAr0>C=rlIA+JWq3 zW0auKJc3gOg9z^$@_1MR2#zGd0|@9*grd$`)~_HGlc8go-rZssd)vnQ5ytQ{S_oI# z>{iaS@Ti{iElKyV?4>iSkhK~KSgoyQ!aOy8_v3qua0{-M5Pqi z8n+tnKm7CP;(S7P!&!vUaC!l6;lsS%TX?~SGlFxl4X-HX8{UUto_t2f;WS)^n{bY` z`@v5Vb{Sh)4(SUx2{+*hyyj^5yu5}da1Bnt{qg2f!3+7Hzg(J!b8r(b!)Z89K1*g; z{C>HF->x$~n8kva^f;=jm%)|(AZD^cR{I_M_0Rf5U8Mi7i{317nl;aOqw4*yM(b9b zdlHS`v9C$~um7J4yJsPr{*e5Y%CB!*-W$@Y%=g$Kf9Q7|mn6psBi2SY@iMgzrALPT z{d2t@RHC651bxyDuCM)<3xuJc8juu`p08w?#4x>!y=8%^pv*2nEN%dSaMJnGIOr>v=SOK_=vQ5^dcJ*&(x6qBYOk#p0RX~uL<1{} zc>mld+v-tfty(QQYFw*i@ecuI`PMbm6{}S{n0jokPEaa?6KC%v1*sdZF1E&;+diop zO7V1G%V-%@(&XQlzR!5R`M4Z)e9u%CO4=SxEk)aCKy7LvYt#8au8R%P5OI7h9B|D8 zj7&r$Ga{a`|CT}8qcK#UsutB2SjyfFaD_3{YjV-_&2|Ya#S*MvXM#p1^qDDi$jkUZ zJxqD6HP!{imzGc#Xw3Xq}1TPu6L?$>-AUc9c!IJ6cR1=ppWe^2VKH zZ_ovb57-06Jh{_KeOM1cDGm`dmu{+sIA;C*Fa*5qZ zM4_wi6^?Uu#e`Vg4~nE8*4_{s0kHhF-!eXa@WICeFX^OzxB%DV99+-eo!y{k;Xa&( zDZ7vTE%jDD`1B(?xL(h&kDn&fwB~Y!SIMgB#^9vrftu{Z`Vd95^~o)ZwyMfK&ZIS* zVU;#ROY7HoAdjo-S+>5KK3Eh}Ls)8Cw%z(J&7{oIKi1Gs&(_wqI(C^VgovqT0F&}1 zOcg1F7n(jo*(W0eecNstBIQ@C4L4!9#o~>s!pHZ@SH@Hdi9wRkSjY`fF1im<;eMK} zaNt>E>(qj%m8ID(zNzg1xrI^q$9~{2knFCd7!Qf;JYM7nmr~Y zrQZ3tUjbVX()Z3<);g^si_lnN+~c;1a#<1~hNpKhwh3e&U>JezFn3z8Wf958y*42h z#rnzzSlVMFV~8d3@hdWPcDc^5Mwx;z2(&ky?^P}(Oy$RtLyy9F*sV?Ch^*}hXtsVh zlUC_~A==0Shy@rEwK~f(ZSK8-W5YTTVQG(MZPKPo!MPCW!i7~ElkD3BH9b}y8e=zg zRU5Xufgh5UGQ!ZP~t9ES?U6j=ue}Jhdc7$N&N(l3UMerRc+rb=tdDx|GuQwb$h_9euz$Av6kZBHvHe{Q zK9Se*80y-;IunPh)3B-8v&$xWb|wzr5A1Nne{qS;IMT2twK-CU7Vho|n)LW$vfR{R z%{WsQe-@?Siz1(|r|J4UFBV*s?KN|`5Qbh=>`W(zmEu^$&5!1N8A(E_L9T&l19*CZ z&`xQ3F^@OoLSnM_9RSjOt;oZWrzRU>JZc~b7jV8yp3(eIuA!1wXvwxdg#aW5dqi< zJN|Ra3Tc#hMFYBhS7xDazy8-io3C<+s+IF}*Oa-S%?8EF!o3ap4yNg1TtV-{OXi}1 zl#fn7yfG6u^QDUpJpxzsRX9~JY|=jPCdu;ZKR>V2Y`9hEA(VO5pKOel_2s;9M0ZaW zV+2sM*eKe!nWj5HUqJ8cEBw$u6#jjDXX_v4ZAbjwSNr7u+8?auA)9@_|K0xVBcH>V z5R4Hv-O?JHr(TfIR=FsyEW=5+?g)sRmX1${%K%3_r?K`sjEig8aqY%Sjs0D-Rs0?|VpF{PF%}mDtwq?haqukb$tL6gd&u-5I*hWw~dp z5MoR;l&55o)OFNxcI%gqa#qD@F}Hx_@@MG=Md@@Vq+$~_+re~>nhsi-`F75;1>smQ_|36%GZI z+SAp4hqEt>$56vC6N@AX@TGJ5CQ6dG3^!0*uTGJtY5v0iLp<#vqh_U{n=F&3g{^Z# zY^pCvQT>5B-WqrtdcQ{yEzbUDwrz5N62h{Bm}sf~-*o{W2+2D^3u_S7$sf~r6H(Pt=xQ@1GW5!2&J3wS|b0 z{q{~I8jYmnSR}G7XHCEnp6u*wZAGN3G$>Oja+&&kvcRTx9k(cOO=Nrn0#EYDu#tv` z>@Sz$fcpO_rj{dTs@FiOEh?AbmV05 z@)#qS&_{>jKA)zT1*1@Sz-*lfHXBP*=}92`Ftts;$tco1HZS$9?hURtjUPxY=?5`W zC+@iXfLeRrgi!a|Ub%xK`e+J7FGgejR4HeMO8t@!opO1V7|h|h`cyD;1YVjdNhA7? zsa5!@E9Z%oQ7~VWrCNw2+W?33(+0URSK|M(*|hMNj!?v?tG}^^|7{1@jM=6NiBOTI z(eE@BUg)>+TvC~om>a3P*x80Z5P+~k`uhTE{#y7y`>%c1^|}W5kK$+;FXq1YUab0c zKIMOZL`SOpIE=^<^kY1td&=*4(5+K&9$W{9o$p_a>CIo@VArPa_!CF56U%Ve+V}}G z00xxfJZ{7`TAdwM-Va0D3zrlZ6)SenLU^<@w3%836N0fpAso&Ha0#48Fn8mU8{LO5 z(YqW+j@tPNx5q7T56p1!t37E_qX*ytSmr_f(i~PmR|rAjTyxRYKE3oeI-x;P2ZDOP z(jSJ6GhKVq`xhH8YBOsSwd%KLxpR)1B4#)`fn}1AYW|A>hFh!@@Bd~Ijq6i0MMdS zxHJj&!Hp4gmi9!*1c*Lnr3h5Y<5c@`d{J1eg9gymDIp`C5Dn?q{ILtwYS*n(ml03j zF}t;HYzrFmcvITlHKbXac6T5QoBBN3KlT{H5C#}hzASE1zZRlC!-7T=jT$(%dfUD} zGoM-I;Y+A*_KyuenWBLYQ7LyscV{U>`E~q@26*bJyW?^RcoBxX@2Eb_h5}X@bmi2W zL?P%NdV?Nb%rPgN5j22E)TvMV5#}Q|KXf*;vHsXaSCwPvCCqlio>0fWpy6-dzj(rn E0JUFZx&QzG literal 37240 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