diff --git a/.tmp-gh-cache/gh/run-log-26624438610-1780039920.zip b/.tmp-gh-cache/gh/run-log-26624438610-1780039920.zip new file mode 100644 index 000000000..4158ec3e4 Binary files /dev/null and b/.tmp-gh-cache/gh/run-log-26624438610-1780039920.zip differ diff --git a/frontend/apps/web/app/feedback-server.test.ts b/frontend/apps/web/app/feedback-server.test.ts new file mode 100644 index 000000000..a43740a20 --- /dev/null +++ b/frontend/apps/web/app/feedback-server.test.ts @@ -0,0 +1,283 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest' + +const mocks = vi.hoisted(() => ({ + createChangeOps: vi.fn((_input: unknown) => ({ + unsignedBytes: new Uint8Array([1, 2, 3]), + ts: BigInt(123), + })), + createChange: vi.fn(async (_input: unknown, _signer: unknown) => ({ + bytes: new Uint8Array([4, 5, 6]), + cid: {toString: () => 'bafy-feedback-version'}, + })), + createVersionRef: vi.fn(async (_input: unknown, _signer: unknown) => ({ + blobs: [{cid: 'bafy-feedback-ref', data: new Uint8Array([7, 8, 9])}], + })), + publish: vi.fn(async (_input: unknown) => ({cids: []})), + fetch: vi.fn(async (_input: unknown) => new Response(new Uint8Array([10, 11, 12]))), + pushResourcesToPeer: vi.fn(async function* (_input: unknown) { + yield { + blobsAnnounced: 2, + blobsKnown: 0, + blobsWanted: 2, + blobsProcessed: 2, + blobsFailed: 0, + } + }), + config: { + registeredAccountUid: 'nodos-aprendizaje-uid', + feedbackDestinationAccountUid: 'seed-surveys-uid', + feedbackDestinationLabel: 'seed-surveys.hyper.media', + feedbackSignerAccountUid: 'nodos-conocimiento-uid', + feedbackDestinationCapabilityCid: 'cap-seed-surveys-writer', + feedbackDocumentVisibility: undefined as 'private' | 'public' | undefined, + feedbackDestinationPeerAddrs: ['/dns4/seed-surveys.example/tcp/56001/p2p/seed-surveys-peer'], + feedbackTaskUrl: 'https://configured-task.example', + feedbackTaskLabel: 'Configured Task', + }, +})) + +vi.mock('@seed-hypermedia/client', () => ({ + createChangeOps: mocks.createChangeOps, + createChange: mocks.createChange, + createVersionRef: mocks.createVersionRef, + signDocumentChange: vi.fn(), +})) + +vi.mock('./site-config.server', () => ({ + getConfig: vi.fn(async () => mocks.config), +})) + +vi.mock('./server-signing', () => ({ + getServerSigningKey: vi.fn(async () => ({ + name: 'server-key-name', + signer: { + getPublicKey: vi.fn(async () => new Uint8Array([1, 2, 3])), + sign: vi.fn(async () => new Uint8Array(64)), + }, + })), +})) + +vi.mock('./client.server', () => ({ + grpcClient: { + resources: { + pushResourcesToPeer: mocks.pushResourcesToPeer, + }, + }, +})) + +vi.mock('./server-universal-client', () => ({ + serverUniversalClient: { + publish: mocks.publish, + }, +})) + +vi.mock('./report-error', () => ({ + reportError: vi.fn(), +})) + +import {action} from './routes/hm.api.feedback' + +describe('feedback server endpoint', () => { + beforeEach(() => { + vi.stubGlobal('fetch', mocks.fetch) + mocks.createChangeOps.mockClear() + mocks.createChangeOps.mockReturnValue({ + unsignedBytes: new Uint8Array([1, 2, 3]), + ts: BigInt(123), + }) + mocks.createChange.mockClear() + mocks.createChange.mockResolvedValue({ + bytes: new Uint8Array([4, 5, 6]), + cid: {toString: () => 'bafy-feedback-version'}, + }) + mocks.createVersionRef.mockClear() + mocks.createVersionRef.mockResolvedValue({ + blobs: [{cid: 'bafy-feedback-ref', data: new Uint8Array([7, 8, 9])}], + }) + mocks.publish.mockClear() + mocks.publish.mockResolvedValue({cids: []}) + mocks.fetch.mockClear() + mocks.fetch.mockResolvedValue(new Response(new Uint8Array([10, 11, 12]))) + mocks.pushResourcesToPeer.mockClear() + mocks.pushResourcesToPeer.mockImplementation(async function* (_input: unknown) { + yield { + blobsAnnounced: 2, + blobsKnown: 0, + blobsWanted: 2, + blobsProcessed: 2, + blobsFailed: 0, + } + }) + mocks.config.feedbackDocumentVisibility = undefined + }) + + it('publishes submitted feedback into the configured destination account', async () => { + const request = new Request('https://nodosdeaprendizaje.es/hm/api/feedback', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({firstImpression: 'Me ayuda a entender la red.'}), + }) + + const response = await action({request, params: {}, context: {}}) + const body = (await response.json()) as { + destinationLabel: string + documentId: string + documentVersion: string + documentPath: string[] + visibility: 'private' | 'public' + } + + expect(response.status).toBe(200) + expect(body.destinationLabel).toBe('seed-surveys.hyper.media') + expect(body.visibility).toBe('private') + expect(body.documentId).toContain('hm://seed-surveys-uid/') + expect(body.documentVersion).toBe('bafy-feedback-version') + expect(body.documentPath).toHaveLength(1) + expect(mocks.createChangeOps).toHaveBeenCalledWith( + expect.objectContaining({ + ops: expect.arrayContaining([expect.objectContaining({type: 'SetAttributes'})]), + ts: expect.any(BigInt), + }), + ) + const operationsText = JSON.stringify((mocks.createChangeOps.mock.calls[0]?.[0] as {ops?: unknown[]})?.ops) + expect(operationsText).toContain('Configured Task') + expect(operationsText).toContain('https://configured-task.example') + expect(mocks.createVersionRef).toHaveBeenCalledWith( + expect.objectContaining({ + space: 'seed-surveys-uid', + path: expect.stringMatching(/^\/.+/), + genesis: 'bafy-feedback-version', + version: 'bafy-feedback-version', + generation: 123, + capability: 'cap-seed-surveys-writer', + visibility: 'Private', + }), + expect.any(Object), + ) + expect(mocks.fetch).toHaveBeenCalledWith('https://seed-surveys.hyper.media/ipfs/cap-seed-surveys-writer') + expect(mocks.publish).toHaveBeenCalledWith({ + blobs: [ + {cid: 'cap-seed-surveys-writer', data: expect.any(Uint8Array)}, + {cid: 'bafy-feedback-version', data: expect.any(Uint8Array)}, + {cid: 'bafy-feedback-ref', data: expect.any(Uint8Array)}, + ], + }) + expect(mocks.fetch).toHaveBeenCalledWith( + 'https://seed-surveys.hyper.media/api/PublishBlobs', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Accept: 'application/json', + 'Content-Type': 'application/cbor', + }), + body: expect.any(Uint8Array), + }), + ) + expect(mocks.pushResourcesToPeer).toHaveBeenCalledWith({ + resources: [body.documentId], + addrs: ['/dns4/seed-surveys.example/tcp/56001/p2p/seed-surveys-peer'], + recursive: false, + }) + }) + + it('can publish public feedback when configured for debugging', async () => { + mocks.config.feedbackDocumentVisibility = 'public' + const request = new Request('https://nodosdeaprendizaje.es/hm/api/feedback', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({firstImpression: 'Public debug feedback.'}), + }) + + const response = await action({request, params: {}, context: {}}) + const body = (await response.json()) as {visibility: 'private' | 'public'} + + expect(response.status).toBe(200) + expect(body.visibility).toBe('public') + expect(mocks.createVersionRef).toHaveBeenCalledWith( + expect.objectContaining({ + space: 'seed-surveys-uid', + visibility: undefined, + }), + expect.any(Object), + ) + expect(mocks.pushResourcesToPeer).toHaveBeenCalledTimes(1) + }) + + it('returns success when destination push announces no blobs after local publish', async () => { + mocks.pushResourcesToPeer.mockImplementation(async function* (_input: unknown) { + yield { + blobsAnnounced: 0, + blobsKnown: 0, + blobsWanted: 0, + blobsProcessed: 0, + blobsFailed: 0, + } + }) + const request = new Request('https://nodosdeaprendizaje.es/hm/api/feedback', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({firstImpression: 'Push announces nothing.'}), + }) + + const response = await action({request, params: {}, context: {}}) + + expect(response.status).toBe(200) + expect(mocks.publish).toHaveBeenCalledTimes(1) + expect(mocks.pushResourcesToPeer).toHaveBeenCalledTimes(1) + }) + + it('fails when direct destination publish fails', async () => { + mocks.fetch.mockImplementation(async (input: unknown) => { + if (String(input).endsWith('/api/PublishBlobs')) { + return new Response('destination unavailable', {status: 503}) + } + return new Response(new Uint8Array([10, 11, 12])) + }) + const request = new Request('https://nodosdeaprendizaje.es/hm/api/feedback', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({firstImpression: 'Destination should fail.'}), + }) + + const response = await action({request, params: {}, context: {}}) + + expect(response.status).toBe(500) + expect(mocks.publish).toHaveBeenCalledTimes(1) + expect(mocks.pushResourcesToPeer).not.toHaveBeenCalled() + }) + + it('returns success when destination push fails after local publish', async () => { + mocks.pushResourcesToPeer.mockImplementation(async function* (_input: unknown) { + yield { + blobsAnnounced: 2, + blobsKnown: 0, + blobsWanted: 2, + blobsProcessed: 2, + blobsFailed: 1, + } + }) + const request = new Request('https://nodosdeaprendizaje.es/hm/api/feedback', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({firstImpression: 'Push should fail.'}), + }) + + const response = await action({request, params: {}, context: {}}) + + expect(response.status).toBe(200) + expect(mocks.publish).toHaveBeenCalledTimes(1) + expect(mocks.pushResourcesToPeer).toHaveBeenCalledTimes(1) + }) + + it('rejects empty feedback server-side', async () => { + const request = new Request('https://nodosdeaprendizaje.es/hm/api/feedback', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({name: 'Only Name'}), + }) + + const response = await action({request, params: {}, context: {}}) + + expect(response.status).toBe(400) + }) +}) diff --git a/frontend/apps/web/app/feedback.test.ts b/frontend/apps/web/app/feedback.test.ts new file mode 100644 index 000000000..0dcc442e8 --- /dev/null +++ b/frontend/apps/web/app/feedback.test.ts @@ -0,0 +1,157 @@ +import {describe, expect, it, vi} from 'vitest' + +vi.mock('@seed-hypermedia/client', () => ({ + signDocumentChange: vi.fn(async () => ({ + changeCid: {toString: () => 'bafy-feedback-version'}, + publishInput: {blobs: [{cid: 'bafy-feedback-version', data: new Uint8Array([1])}]}, + })), +})) + +import { + buildFeedbackDocumentMarkdown, + buildFeedbackDocumentTitle, + formatFeedbackTimestamp, + hasMeaningfulFeedback, + normalizeFeedbackFormValues, + publishFeedbackDocument, + resolveFeedbackTaskConfig, + type FeedbackFormValues, +} from './feedback' + +function makeValues(overrides: Partial = {}): FeedbackFormValues { + return { + name: '', + email: '', + firstImpression: '', + possibleActions: '', + howToComment: '', + howToShare: '', + clarity: '', + foundCommentButton: '', + oneChange: '', + readingPreference: '', + ...overrides, + } +} + +describe('feedback helpers', () => { + it('normalizes free-text fields at submit boundaries', () => { + expect( + normalizeFeedbackFormValues( + makeValues({ + name: ' Ada ', + email: ' ada@example.com ', + firstImpression: ' unclear but interesting ', + }), + ), + ).toMatchObject({ + name: 'Ada', + email: 'ada@example.com', + firstImpression: 'unclear but interesting', + }) + }) + + it('requires a real feedback field instead of name/email only', () => { + expect(hasMeaningfulFeedback(makeValues({name: 'Ada', email: 'ada@example.com'}))).toBe(false) + expect(hasMeaningfulFeedback(makeValues({clarity: '4'}))).toBe(true) + }) + + it('formats timestamps and titles consistently', () => { + const submittedAt = formatFeedbackTimestamp(new Date(2026, 4, 28, 14, 32)) + expect(submittedAt).toBe('2026-05-28 14:32') + expect(buildFeedbackDocumentTitle(submittedAt, 'nodosdeconocimiento.es')).toBe( + 'Feedback on nodosdeconocimiento.es — 2026-05-28 14:32', + ) + }) + + it('resolves feedback task target from config with safe defaults', () => { + expect(resolveFeedbackTaskConfig(null)).toEqual({ + url: 'https://seedsurveys.com', + label: 'seedsurveys.com', + }) + expect(resolveFeedbackTaskConfig({feedbackTaskUrl: 'https://example.org/path'})).toEqual({ + url: 'https://example.org/path', + label: 'example.org', + }) + expect( + resolveFeedbackTaskConfig({ + feedbackTaskUrl: 'https://example.org/path', + feedbackTaskLabel: 'Example task', + }), + ).toEqual({ + url: 'https://example.org/path', + label: 'Example task', + }) + }) + + it('builds markdown with context markers and omits empty sections', () => { + const markdown = buildFeedbackDocumentMarkdown( + makeValues({ + firstImpression: 'Me pareció una biblioteca viva.', + clarity: '4 / 5', + }), + { + submittedAt: '2026-05-28 14:32', + publishedUnderLabel: 'Ethosfera', + publishedUnderAccountUid: 'z6MkSite', + testedPageLabel: 'nodosdeconocimiento.es', + testedPageUrl: 'https://nodosdeconocimiento.es', + visibilityLabel: 'Privado', + }, + ) + + expect(markdown).toContain('Feedback enviado mediante formulario web.') + expect(markdown).toContain('- Formulario: /feedback') + expect(markdown).toContain('- Página evaluada: nodosdeconocimiento.es') + expect(markdown).toContain('- URL: https://nodosdeconocimiento.es') + expect(markdown).toContain('- Sitio participante: Ethosfera') + expect(markdown).toContain('- Cuenta de destino: z6MkSite') + expect(markdown).toContain('## Primera impresión') + expect(markdown).toContain('## Qué tan claro quedó para qué sirve') + expect(markdown).not.toContain('## Nombre') + expect(markdown).not.toContain('## Email') + }) + + it('publishes a private feedback document with the generated path and capability', async () => { + const request = vi.fn(async (_method: string, _payload: unknown) => ({unsignedChange: new Uint8Array([1, 2, 3])})) + const publish = vi.fn(async (_input: {blobs: Array<{cid?: string; data: Uint8Array}>}) => ({cids: []})) + const signer = { + getPublicKey: vi.fn(async () => new Uint8Array([1, 2, 3])), + sign: vi.fn(async () => new Uint8Array(64)), + } + + const result = await publishFeedbackDocument( + { + request: request as any, + publish, + getSigner: () => signer, + generatePath: () => 'private-feedback-path', + now: () => new Date(2026, 4, 28, 14, 32), + }, + makeValues({firstImpression: 'Muy interesante'}), + { + publishAccountUid: 'site-uid', + signingAccountUid: 'delegated-user', + capabilityCid: 'cap-123', + publishedUnderLabel: 'Ethosfera', + publishedUnderAccountUid: 'site-uid', + testedPageLabel: 'nodosdeconocimiento.es', + testedPageUrl: 'https://nodosdeconocimiento.es', + }, + ) + + expect(request).toHaveBeenCalledTimes(1) + expect(request.mock.calls[0]?.[0]).toBe('PrepareDocumentChange') + expect(request.mock.calls[0]?.[1]).toMatchObject({ + account: 'site-uid', + path: '/private-feedback-path', + capability: 'cap-123', + visibility: 2, + }) + expect(publish).toHaveBeenCalledTimes(1) + expect(result.documentId.uid).toBe('site-uid') + expect(result.documentId.path).toEqual(['private-feedback-path']) + expect(result.documentId.version).toBe('bafy-feedback-version') + expect(result.title).toContain('Feedback on nodosdeconocimiento.es') + }) +}) diff --git a/frontend/apps/web/app/feedback.ts b/frontend/apps/web/app/feedback.ts new file mode 100644 index 000000000..58b30bae2 --- /dev/null +++ b/frontend/apps/web/app/feedback.ts @@ -0,0 +1,290 @@ +import {signDocumentChange, type DocumentOperation} from '@seed-hypermedia/client' +import type {EditorBlock} from '@seed-hypermedia/client/editor-types' +import {hmBlocksToEditorContent} from '@seed-hypermedia/client/hmblock-to-editorblock' +import type {HMMetadata, HMSigner, UnpackedHypermediaId} from '@seed-hypermedia/client/hm-types' +import { + flattenToOperations, + markdownBlockNodesToHMBlockNodes, + parseMarkdown, +} from '@seed-hypermedia/client/markdown-to-blocks' +import {DocumentChange, ResourceVisibility} from '@shm/shared/client/.generated/documents/v3alpha/documents_pb' +import {hmId} from '@shm/shared/utils/entity-id-url' +import {hmIdPathToEntityQueryPath} from '@shm/shared/utils/path-api' +import {compareBlocksWithMap, getDocAttributeChanges} from '@shm/shared/utils/document-changes' +import type {UniversalClient} from '@shm/shared/universal-client' +import {nanoid} from 'nanoid' + +/** Route path for the web feedback form. */ +export const FEEDBACK_ROUTE_PATH = '/feedback' + +/** Static configuration for the feedback route shell. */ +export const FEEDBACK_CONFIG = { + pageTitle: 'Feedback', + productLabel: 'Seed Hypermedia', + taskUrl: 'https://seedsurveys.com', + taskLabel: 'seedsurveys.com', +} as const + +/** Configurable task target shown and recorded by the feedback form. */ +export type FeedbackTaskConfig = { + feedbackTaskUrl?: string + feedbackTaskLabel?: string +} + +/** Structured values captured by the `/feedback` form. */ +export type FeedbackFormValues = { + name: string + email: string + firstImpression: string + possibleActions: string + howToComment: string + howToShare: string + clarity: string + foundCommentButton: string + oneChange: string + readingPreference: string +} + +type FeedbackDocumentContext = { + submittedAt: string + publishedUnderLabel: string + publishedUnderAccountUid: string + testedPageLabel: string + testedPageUrl: string + visibilityLabel: string +} + +type FeedbackPublishContext = { + publishAccountUid: string + signingAccountUid: string + publishedUnderLabel: string + publishedUnderAccountUid: string + testedPageLabel: string + testedPageUrl: string + visibility?: ResourceVisibility + visibilityLabel?: string + capabilityCid?: string +} + +type FeedbackPublishDeps = Pick & { + getSigner?: (accountUid: string) => HMSigner + generatePath?: () => string + now?: () => Date +} + +/** Payload needed to publish a feedback document through either JS signing or the daemon. */ +export type FeedbackDocumentPublishPayload = { + pathSegment: string + path: string + changes: DocumentChange[] + operations: DocumentOperation[] + title: string + submittedAt: string + submittedAtDate: Date + visibility: ResourceVisibility +} + +/** Result of publishing a feedback document. */ +export type PublishedFeedbackDocument = { + documentId: UnpackedHypermediaId + title: string + submittedAt: string +} + +/** Resolve the task URL/label from site config, falling back to the default Nodos task. */ +export function resolveFeedbackTaskConfig(config?: FeedbackTaskConfig | null): {url: string; label: string} { + const url = config?.feedbackTaskUrl?.trim() || FEEDBACK_CONFIG.taskUrl + const label = config?.feedbackTaskLabel?.trim() || getFeedbackTaskLabelFromUrl(url) || FEEDBACK_CONFIG.taskLabel + return {url, label} +} + +function getFeedbackTaskLabelFromUrl(url: string): string | null { + try { + return new URL(url).host + } catch { + return url.trim() || null + } +} + +const FEEDBACK_PROMPTS: Array<{key: keyof FeedbackFormValues; title: string}> = [ + {key: 'firstImpression', title: 'Primera impresión'}, + {key: 'possibleActions', title: 'Qué pensó que podía hacer y cómo participar'}, + {key: 'howToComment', title: 'Cómo empezaría o participaría en una conversación'}, + {key: 'howToShare', title: 'Cómo compartiría un párrafo de interés'}, + {key: 'clarity', title: 'Qué tan claro quedó para qué sirve'}, + {key: 'foundCommentButton', title: 'Encontró cómo participar en una conversación'}, + {key: 'oneChange', title: 'Una cosa que cambiaría'}, + {key: 'readingPreference', title: 'Cómo preferiría leer o explorar el contenido'}, +] + +/** Trim and normalize form values at submit time. */ +export function normalizeFeedbackFormValues(values: FeedbackFormValues): FeedbackFormValues { + return { + name: values.name.trim(), + email: values.email.trim(), + firstImpression: values.firstImpression.trim(), + possibleActions: values.possibleActions.trim(), + howToComment: values.howToComment.trim(), + howToShare: values.howToShare.trim(), + clarity: values.clarity.trim(), + foundCommentButton: values.foundCommentButton.trim(), + oneChange: values.oneChange.trim(), + readingPreference: values.readingPreference.trim(), + } +} + +/** Return true when at least one real feedback field (not name/email) is present. */ +export function hasMeaningfulFeedback(values: FeedbackFormValues): boolean { + return FEEDBACK_PROMPTS.some(({key}) => values[key].trim().length > 0) +} + +/** Format a submission timestamp as `YYYY-MM-DD HH:mm` in the user's local time. */ +export function formatFeedbackTimestamp(date: Date): string { + const year = date.getFullYear() + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + const hours = `${date.getHours()}`.padStart(2, '0') + const minutes = `${date.getMinutes()}`.padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}` +} + +/** Build the generic private feedback document title. */ +export function buildFeedbackDocumentTitle(submittedAt: string, testedPageLabel: string): string { + return `Feedback on ${testedPageLabel} — ${submittedAt}` +} + +/** Build the markdown body that will be published into the private feedback document. */ +export function buildFeedbackDocumentMarkdown(values: FeedbackFormValues, context: FeedbackDocumentContext): string { + const sections: string[] = [ + 'Feedback enviado mediante formulario web.', + '', + '## Contexto', + '- Tipo: Feedback', + `- Formulario: ${FEEDBACK_ROUTE_PATH}`, + '- Origen: Formulario web', + `- Página evaluada: ${context.testedPageLabel}`, + `- URL: ${context.testedPageUrl}`, + `- Fecha de envío: ${context.submittedAt}`, + `- Sitio participante: ${context.publishedUnderLabel}`, + `- Cuenta de destino: ${context.publishedUnderAccountUid}`, + `- Visibilidad: ${context.visibilityLabel}`, + ] + + if (values.name) { + sections.push('', '## Nombre', values.name) + } + if (values.email) { + sections.push('', '## Email', values.email) + } + + FEEDBACK_PROMPTS.forEach(({key, title}) => { + const value = values[key] + if (!value) return + sections.push('', `## ${title}`, value) + }) + + return sections.join('\n') +} + +/** Convert feedback markdown into editor blocks suitable for document diffing/publish. */ +export function feedbackMarkdownToEditorBlocks(markdown: string): EditorBlock[] { + const {tree} = parseMarkdown(markdown) + const content = markdownBlockNodesToHMBlockNodes(tree) + return hmBlocksToEditorContent(content, {childrenType: 'Group'}) as EditorBlock[] +} + +/** Build the change payload for a new feedback document. */ +export function buildFeedbackDocumentPublishPayload( + values: FeedbackFormValues, + context: Omit, + options: Pick = {}, +): FeedbackDocumentPublishPayload { + const submittedAtDate = options.now?.() ?? new Date() + const submittedAt = formatFeedbackTimestamp(submittedAtDate) + const visibility = context.visibility ?? ResourceVisibility.PRIVATE + const visibilityLabel = context.visibilityLabel ?? 'Privado' + const title = buildFeedbackDocumentTitle(submittedAt, context.testedPageLabel) + const markdown = buildFeedbackDocumentMarkdown(values, { + submittedAt, + publishedUnderLabel: context.publishedUnderLabel, + publishedUnderAccountUid: context.publishedUnderAccountUid, + testedPageLabel: context.testedPageLabel, + testedPageUrl: context.testedPageUrl, + visibilityLabel, + }) + const {tree} = parseMarkdown(markdown) + const content = markdownBlockNodesToHMBlockNodes(tree) + const editorBlocks = hmBlocksToEditorContent(content, {childrenType: 'Group'}) as EditorBlock[] + const {changes} = compareBlocksWithMap({}, editorBlocks, '') + const metadataChanges = getDocAttributeChanges({name: title} as HMMetadata) + const pathSegment = options.generatePath?.() ?? nanoid(21) + const operations: DocumentOperation[] = [ + {type: 'SetAttributes', attrs: [{key: ['name'], value: title}]}, + ...flattenToOperations(tree), + ] + + return { + pathSegment, + path: hmIdPathToEntityQueryPath([pathSegment]), + changes: [...metadataChanges, ...changes], + operations, + title, + submittedAt, + submittedAtDate, + visibility, + } +} + +/** Publish a new feedback document with the JS signing path and return its concrete document id. */ +export async function publishFeedbackDocument( + deps: FeedbackPublishDeps, + values: FeedbackFormValues, + context: FeedbackPublishContext, +): Promise { + if (!deps.getSigner) { + throw new Error('Feedback publish requires a browser signer') + } + + const payload = buildFeedbackDocumentPublishPayload( + values, + { + publishedUnderLabel: context.publishedUnderLabel, + publishedUnderAccountUid: context.publishedUnderAccountUid, + testedPageLabel: context.testedPageLabel, + testedPageUrl: context.testedPageUrl, + visibility: context.visibility, + visibilityLabel: context.visibilityLabel, + }, + deps, + ) + const signer = deps.getSigner(context.signingAccountUid) + + const prepareResult = (await deps.request('PrepareDocumentChange', { + account: context.publishAccountUid, + path: payload.path, + baseVersion: '', + changes: payload.changes as any, + capability: context.capabilityCid ?? '', + visibility: payload.visibility, + })) as {unsignedChange: Uint8Array} + + const {changeCid, publishInput} = await signDocumentChange( + { + account: context.publishAccountUid, + path: payload.path, + unsignedChange: prepareResult.unsignedChange, + generation: payload.submittedAtDate.getTime(), + capability: context.capabilityCid ?? '', + visibility: payload.visibility, + }, + signer, + ) + + await deps.publish(publishInput) + + return { + documentId: hmId(context.publishAccountUid, {path: [payload.pathSegment], version: changeCid.toString()}), + title: payload.title, + submittedAt: payload.submittedAt, + } +} diff --git a/frontend/apps/web/app/routes/feedback.tsx b/frontend/apps/web/app/routes/feedback.tsx new file mode 100644 index 000000000..caf9ed8b5 --- /dev/null +++ b/frontend/apps/web/app/routes/feedback.tsx @@ -0,0 +1,559 @@ +import { + FEEDBACK_CONFIG, + FEEDBACK_ROUTE_PATH, + type FeedbackFormValues, + hasMeaningfulFeedback, + normalizeFeedbackFormValues, + resolveFeedbackTaskConfig, +} from '@/feedback' +import {loadSiteHeaderData, type SiteHeaderPayload} from '@/loaders' +import {defaultSiteIcon} from '@/meta' +import {PageFooter} from '@/page-footer' +import {getOptimizedImageUrl, NavigationLoadingContent, WebSiteProvider} from '@/providers' +import {parseRequest} from '@/request' +import {reportError} from '@/report-error' +import {getConfig} from '@/site-config.server' +import {WebSiteHeader} from '@/web-site-header' +import {unwrap} from '@/wrapping' +import {wrapJSON} from '@/wrapping.server' +import {getDaemonAuthToken, withDaemonAuthToken} from '@/daemon-auth.server' +import {LoaderFunctionArgs, LinksFunction, MetaFunction} from '@remix-run/node' +import {MetaDescriptor, useLoaderData} from '@remix-run/react' +import {Button} from '@shm/ui/button' +import {Input} from '@shm/ui/components/input' +import {Textarea} from '@shm/ui/components/textarea' +import {extractIpfsUrlCid} from '@shm/ui/get-file-url' +import {Spinner} from '@shm/ui/spinner' +import {cn} from '@shm/ui/utils' +import {useMutation} from '@tanstack/react-query' +import {Check} from 'lucide-react' +import type {FormEvent, ReactNode} from 'react' +import {useState} from 'react' + +type FeedbackPagePayload = SiteHeaderPayload & { + feedbackDestinationLabel: string | null + feedbackDocumentVisibility: 'private' | 'public' + feedbackTaskUrl: string + feedbackTaskLabel: string +} + +type PublishSuccessState = { + destinationLabel: string + submittedAt: string + visibility: 'private' | 'public' +} + +const FONT_PRECONNECTS = [ + {rel: 'preconnect', href: 'https://fonts.googleapis.com'}, + {rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' as const}, +] + +const INITIAL_VALUES: FeedbackFormValues = { + name: '', + email: '', + firstImpression: '', + possibleActions: '', + howToComment: '', + howToShare: '', + clarity: '', + foundCommentButton: '', + oneChange: '', + readingPreference: '', +} + +const CLARITY_OPTIONS = ['1', '2', '3', '4', '5'] as const +const COMMENT_BUTTON_OPTIONS = ['Sí', 'No', 'No lo busqué'] as const + +/** Load site chrome for the `/feedback` utility page. */ +export const loader = async ({request}: LoaderFunctionArgs) => { + const parsedRequest = parseRequest(request) + const authToken = await getDaemonAuthToken(request) + return withDaemonAuthToken(authToken, async () => { + const [headerData, config] = await Promise.all([ + loadSiteHeaderData(parsedRequest), + getConfig(parsedRequest.hostname), + ]) + const task = resolveFeedbackTaskConfig(config) + return wrapJSON({ + ...headerData, + feedbackDestinationLabel: config?.feedbackDestinationLabel?.trim() || null, + feedbackDocumentVisibility: config?.feedbackDocumentVisibility === 'public' ? 'public' : 'private', + feedbackTaskUrl: task.url, + feedbackTaskLabel: task.label, + } satisfies FeedbackPagePayload) + }) +} + +/** Load the editorial fonts used by the preserved feedback design. */ +export const links: LinksFunction = () => [ + ...FONT_PRECONNECTS, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Sans:wght@300;400;500&display=swap', + }, +] + +/** Set a site-specific browser title and icon for the feedback page. */ +export const meta: MetaFunction = ({data}) => { + const {homeMetadata, siteHost} = unwrap(data) + const meta: MetaDescriptor[] = [] + const homeIconCid = homeMetadata?.icon ? extractIpfsUrlCid(homeMetadata.icon) : null + const homeIcon = homeIconCid ? getOptimizedImageUrl(homeIconCid, 'S') : null + const pageLabel = homeMetadata?.name?.trim() || new URL(siteHost).host + meta.push({ + tagName: 'link', + rel: 'icon', + href: homeIcon || defaultSiteIcon, + type: 'image/png', + }) + meta.push({title: `${FEEDBACK_CONFIG.pageTitle} on ${pageLabel}`}) + return meta +} + +export default function FeedbackRoute() { + const {originHomeId, siteHost, origin, homeMetadata, dehydratedState, feedbackTaskUrl, feedbackTaskLabel} = + unwrap(useLoaderData()) + if (!originHomeId) { + return

Invalid origin home id

+ } + + return ( + +
+ + + + + +
+
+ ) +} + +function FeedbackPageBody({ + originHomeId, + homeMetadata, + siteOrigin, + feedbackTaskUrl, + feedbackTaskLabel, +}: { + originHomeId: NonNullable + homeMetadata: FeedbackPagePayload['homeMetadata'] + siteOrigin: string + feedbackTaskUrl: string + feedbackTaskLabel: string +}) { + const [values, setValues] = useState(INITIAL_VALUES) + const [formError, setFormError] = useState(null) + const [success, setSuccess] = useState(null) + + const siteName = homeMetadata?.name?.trim() || new URL(siteOrigin).host + const testedPageLabel = feedbackTaskLabel + const testedPageUrl = feedbackTaskUrl + const logoCid = homeMetadata?.icon ? extractIpfsUrlCid(homeMetadata.icon) : null + const logoSrc = logoCid ? getOptimizedImageUrl(logoCid, 'M') : null + + const submitFeedback = useMutation({ + mutationFn: async (normalizedValues: FeedbackFormValues) => { + const response = await fetch('/hm/api/feedback', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(normalizedValues), + }) + if (!response.ok) { + throw new Error(`Feedback submit failed with status ${response.status}`) + } + return (await response.json()) as { + destinationLabel: string + submittedAt: string + visibility: 'private' | 'public' + } + }, + onSuccess: (result) => { + setSuccess({ + destinationLabel: result.destinationLabel, + submittedAt: result.submittedAt, + visibility: result.visibility, + }) + setFormError(null) + }, + onError: (error) => { + reportError(error, { + feature: 'feedback', + operation: 'submit-feedback', + publishAccountUid: originHomeId.uid, + route: FEEDBACK_ROUTE_PATH, + }) + setFormError('No hemos podido guardar tu feedback. Puedes revisar tus respuestas e intentarlo de nuevo.') + }, + }) + + const submitDisabled = submitFeedback.isPending + + function updateField(key: K, value: FeedbackFormValues[K]) { + setValues((current) => ({...current, [key]: value})) + } + + function handleSubmit(event: FormEvent) { + event.preventDefault() + const normalizedValues = normalizeFeedbackFormValues(values) + setValues(normalizedValues) + + if (!hasMeaningfulFeedback(normalizedValues)) { + setFormError('Añade al menos una respuesta de feedback antes de enviarlo.') + return + } + + setFormError(null) + submitFeedback.mutate(normalizedValues) + } + + return ( +
+
+
+ {logoSrc ? ( +
+ {siteName} +
+ ) : null} + +
+
+

+ {FEEDBACK_CONFIG.productLabel} +

+

+ Ayúdanos a mejorar +
+ compartiendo tu feedback +

+

+ Estamos construyendo un lugar para crear conocimiento, leer, comentar y conectar — sin un servidor central + que lo controle. + Este test dura unos 8–10 minutos, muchas gracias por tu tiempo. +

+
+ +
+ + Tu tarea + +

+ Abre el enlace, echa un vistazo a lo que ves durante un par de minutos y lee lo que te llame la atención. + A continuación responde a las preguntas. +

+ + ↗ Abrir {testedPageLabel} + +
+ + {success ? ( + } + title="Gracias." + body={`Tu feedback se ha guardado como documento ${ + success.visibility === 'public' ? 'público' : 'privado' + } en ${success.destinationLabel}.`} + > +

Fecha de envío: {success.submittedAt}

+
+ ) : ( +
+ Tu feedback + + + +
+ +
+

2 de 7

+

¿Qué has pensado que podías hacer en este site?

+ +
+ +
+

3 de 7

+

Si te interesara mucho un contenido y quisieras hacer un comentario, ¿cómo lo harías?

+

Si te bloqueaste o lo dejaste a medias, eso es exactamente lo que necesitamos saber

+ +
+ +
+

4 de 7

+

+ Imagina que lees un párrafo que te parece muy interesante y quieres compartirlo con alguien. ¿Cómo lo harías? +

+

No hay respuesta correcta, nos interesa tu intuición

+ +
+ +
+

5 de 7

+

¿Qué tan claro te quedó para qué sirve esta herramienta?

+

1 = nada claro, 5 = completamente obvio

+
+ + + + + +
+
Nada claroCompletamente obvio
+
+ +
+

6 de 7

+

¿Encontraste el botón de comentar?

+
+ + + +
+
+ +
+

7 de 7

+

+ Una cosa que cambiarías para que todo tenga más sentido desde el primer momento y te den ganas de interactuar y + comentar. +

+ +
+ +
+ +
+ +
+ +

Gracias.

+

Tu feedback ha sido recibido.
Nos ayuda muchísimo a construir algo mejor.

+
+ +