diff --git a/package.json b/package.json index 04c4a8885..216ba04b8 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@amplitude/analytics-types": "^2.10.0", "@codexteam/ui": "0.2.0-rc.4", "@hawk.so/javascript": "3.2.13", - "@hawk.so/types": "^0.5.2", + "@hawk.so/types": "^0.5.9", "axios": "^1.12.2", "codex-notifier": "^1.1.2", "cssnano": "^7.1.1", diff --git a/src/App.vue b/src/App.vue index 57caba073..aef27e2e8 100644 --- a/src/App.vue +++ b/src/App.vue @@ -13,6 +13,7 @@ import * as api from './api/'; import { setLanguage } from './i18n'; import { defineComponent } from 'vue'; +import { useDemo } from './composables/useDemo'; import FeedbackButton from './components/utils/FeedbackButton.vue'; export default defineComponent({ @@ -20,6 +21,11 @@ export default defineComponent({ components: { FeedbackButton, }, + setup() { + const { isDemoActive } = useDemo(); + + return { isDemoActive }; + }, computed: { /** * Returns classname according to the theme name @@ -39,7 +45,7 @@ export default defineComponent({ this.$store.watch( state => state.user.accessToken, (accessToken) => { - if (!accessToken) { + if (!accessToken && !this.isDemoActive) { this.$router.push('/login'); } api.setAuthToken(accessToken); diff --git a/src/api/billing/index.ts b/src/api/billing/index.ts index bc2d4847c..9f00c8bfe 100644 --- a/src/api/billing/index.ts +++ b/src/api/billing/index.ts @@ -1,5 +1,6 @@ import { MUTATION_PAY_WITH_CARD, QUERY_BUSINESS_OPERATIONS, QUERY_COMPOSE_PAYMENT } from './queries'; import * as api from '../'; +import { withDemoMock } from '@/utils/withDemoMock'; import type { BusinessOperation } from '../../types/business-operation'; import { BeforePaymentPayload } from '@/types/before-payment-payload'; @@ -7,10 +8,15 @@ import { BeforePaymentPayload } from '@/types/before-payment-payload'; * Request business operations list for passed workspaces * @param ids - ids of workspaces */ -export async function getBusinessOperations(ids: string[]): Promise { +async function getBusinessOperationsRequest(ids: string[]): Promise { return (await api.callOld(QUERY_BUSINESS_OPERATIONS, { ids })).businessOperations; } +export const getBusinessOperations = withDemoMock( + getBusinessOperationsRequest, + '/src/api/billing/mocks/getBusinessOperations.mock.ts' +); + /** * Data for processing payment with saved card */ diff --git a/src/api/billing/mocks/getBusinessOperations.mock.ts b/src/api/billing/mocks/getBusinessOperations.mock.ts new file mode 100644 index 000000000..89026602d --- /dev/null +++ b/src/api/billing/mocks/getBusinessOperations.mock.ts @@ -0,0 +1,15 @@ +import type { BusinessOperation } from '@/types/business-operation'; +import { DEMO_WORKSPACE_ID, DEMO_BUSINESS_OPERATIONS } from '@/api/mock-db'; + +/** + * Mock: getBusinessOperations + * + * Returns demo business operations (payment history) for specified workspace IDs + */ +export default function mockGetBusinessOperations(ids: string[]): BusinessOperation[] { + // Return demo operations if requesting for demo workspace + if (ids.includes(DEMO_WORKSPACE_ID)) { + return DEMO_BUSINESS_OPERATIONS; + } + return []; +} diff --git a/src/api/events/index.ts b/src/api/events/index.ts index 1296e257a..119d34645 100644 --- a/src/api/events/index.ts +++ b/src/api/events/index.ts @@ -22,6 +22,7 @@ import { import type { User } from '@/types/user'; import type { EventChartItem, ChartLine } from '@/types/chart'; import type { APIResponse } from '../../types/api'; +import { withDemoMock } from '@/utils/withDemoMock'; /** * Get specific event @@ -30,19 +31,21 @@ import type { APIResponse } from '../../types/api'; * @param originalEventId - id of the original event * @returns */ -export async function getEvent(projectId: string, eventId: string, originalEventId: string): Promise { - const project = await (await api.callOld(QUERY_EVENT, { - projectId, - eventId, - originalEventId, - })).project; +export const getEvent = withDemoMock( + async function getEvent(projectId: string, eventId: string, originalEventId: string): Promise { + const project = await (await api.callOld(QUERY_EVENT, { + projectId, + eventId, + originalEventId, + })).project; - if (!project) { - return null; - } - - return project.event; -} + if (!project) { + return null; + } + return project.event; + }, + '/src/api/events/mocks/getEvent.mock.ts' +); /** * Returns portion (list) of daily events with pointer to the first daily event of the next portion @@ -53,38 +56,40 @@ export async function getEvent(projectId: string, eventId: string, originalEvent * @param search - search string for daily events * @param release - release identifier to filter events */ -export async function fetchDailyEventsPortion( - projectId: string, - nextCursor: DailyEventsCursor | null = null, +export const fetchDailyEventsPortion = withDemoMock( + async function fetchDailyEventsPortion( + projectId: string, + nextCursor: DailyEventsCursor | null = null, sort = EventsSortOrder.ByDate, filters: EventsFilters = {}, search = '', release?: string -): Promise { - const response = await api.call(QUERY_PROJECT_DAILY_EVENTS, { - projectId, - cursor: nextCursor, - sort, - filters, - search, - release, - }, undefined, { + ): Promise { + const response = await api.call(QUERY_PROJECT_DAILY_EVENTS, { + projectId, + cursor: nextCursor, + sort, + filters, + search, + release, + }, undefined, { /** * This request calls on the app start, so we don't want to break app if something goes wrong * With this flag, errors from the API won't be thrown, but returned in the response for further handling */ - allowErrors: true, - }); + allowErrors: true, + }); - const project = response.data.project; + const project = response.data.project; - if (response.errors?.length) { - response.errors.forEach(e => console.error(e)); - } - - return project?.dailyEventsPortion ?? { cursor: null, - dailyEventsPortion: [] }; -} + if (response.errors?.length) { + response.errors.forEach(e => console.error(e)); + } + return project?.dailyEventsPortion ?? { nextCursor: null, + dailyEvents: [] }; + }, + '/src/api/events/mocks/fetchDailyEventsPortion.mock.ts' +); /** * Fetches event's repetitions portion from project @@ -94,29 +99,31 @@ export async function fetchDailyEventsPortion( * @param cursor - the cursor to fetch the next page of repetitions * @returns */ -export async function getRepetitionsPortion( - projectId: string, originalEventId: string, limit: number, cursor?: string -): Promise> { - const response = await api.call(QUERY_EVENT_REPETITIONS_PORTION, { - limit, - projectId, - originalEventId, - cursor, - }, undefined, { +export const getRepetitionsPortion = withDemoMock( + async function getRepetitionsPortion( + projectId: string, originalEventId: string, limit: number, cursor?: string + ): Promise> { + const response = await api.call(QUERY_EVENT_REPETITIONS_PORTION, { + limit, + projectId, + originalEventId, + cursor, + }, undefined, { /** * This request calls on the app start, so we don't want to break app if something goes wrong * With this flag, errors from the API won't be thrown, but returned in the response for further handling */ - allowErrors: true, - }); + allowErrors: true, + }); - if (response.errors?.length) { - response.errors.forEach(e => console.error(e)); - } - - return response; -} + if (response.errors?.length) { + response.errors.forEach(e => console.error(e)); + } + return response; + }, + '/src/api/events/mocks/getRepetitionsPortion.mock.ts' +); /** * Mark event as visited for current user @@ -124,12 +131,15 @@ export async function getRepetitionsPortion( * @param originalEventId — original event id of the visited one * @returns */ -export async function visitEvent(projectId: string, originalEventId: string): Promise { - return (await api.callOld(MUTATION_VISIT_EVENT, { - projectId, - originalEventId, - })).visitEvent; -} +export const visitEvent = withDemoMock( + async function visitEvent(projectId: string, originalEventId: string): Promise { + return (await api.callOld(MUTATION_VISIT_EVENT, { + projectId, + originalEventId, + })).visitEvent; + }, + '/src/api/events/mocks/visitEvent.mock.ts' +); /** * Set or unset mark to event @@ -137,13 +147,16 @@ export async function visitEvent(projectId: string, originalEventId: string): Pr * @param eventId — event Id * @param mark — mark to set */ -export async function toggleEventMark(projectId: string, eventId: string, mark: EventMark): Promise { - return (await api.callOld(MUTATION_TOGGLE_EVENT_MARK, { - projectId, - eventId, - mark, - })).toggleEventMark; -} +export const toggleEventMark = withDemoMock( + async function toggleEventMark(projectId: string, eventId: string, mark: EventMark): Promise { + return (await api.callOld(MUTATION_TOGGLE_EVENT_MARK, { + projectId, + eventId, + mark, + })).toggleEventMark; + }, + '/src/api/events/mocks/toggleEventMark.mock.ts' +); /** * Update assignee @@ -183,16 +196,19 @@ export async function removeAssignee(projectId: string, eventId: string): Promis * @param days - how many days we need to fetch for displaying in chart * @param timezoneOffset - user's local timezone */ -export async function fetchChartData( - projectId: string, - originalEventId: string, - days: number, - timezoneOffset: number -): Promise { - return (await api.callOld(QUERY_CHART_DATA, { - projectId, - originalEventId, - days, - timezoneOffset, - })).project.event.chartData; -} +export const fetchChartData = withDemoMock( + async function fetchChartData( + projectId: string, + originalEventId: string, + days: number, + timezoneOffset: number + ): Promise { + return (await api.callOld(QUERY_CHART_DATA, { + projectId, + originalEventId, + days, + timezoneOffset, + })).project.event.chartData; + }, + '/src/api/events/mocks/fetchChartData.mock.ts' +); diff --git a/src/api/events/mocks/fetchChartData.mock.ts b/src/api/events/mocks/fetchChartData.mock.ts new file mode 100644 index 000000000..6b3090459 --- /dev/null +++ b/src/api/events/mocks/fetchChartData.mock.ts @@ -0,0 +1,68 @@ +import { getDemoEventsByProjectId } from '@/api/mock-db'; +import type { ChartLine } from '@hawk.so/types'; + +const SECONDS_IN_MINUTE = 60; +const SECONDS_IN_DAY = 24 * 60 * 60; + +function alignToDay(timestamp: number, timezoneOffset = 0): number { + const shiftedTimestamp = timestamp - timezoneOffset * SECONDS_IN_MINUTE; + + return Math.floor(shiftedTimestamp / SECONDS_IN_DAY) * SECONDS_IN_DAY + timezoneOffset * SECONDS_IN_MINUTE; +} + +/** + * Mock: fetchChartData (events) + * + * Returns chart data from mock-db + */ +export default function mockFetchChartData( + projectId: string, + originalEventId: string, + days = 14, + timezoneOffset = 0 +): ChartLine[] { + const nowSeconds = Math.floor(Date.now() / 1000); + const fromTimestamp = nowSeconds - Math.max(1, days) * SECONDS_IN_DAY; + const buckets = new Map(); + + for (let day = 0; day <= days; day++) { + const dayTimestamp = alignToDay(fromTimestamp + day * SECONDS_IN_DAY, timezoneOffset); + + buckets.set(dayTimestamp, 0); + } + + const projectEvents = getDemoEventsByProjectId(projectId); + const anchorEvent = projectEvents.find(event => event.originalEventId === originalEventId); + + const relatedEvents = projectEvents.filter((event) => { + if (event.timestamp < fromTimestamp || event.timestamp > nowSeconds) { + return false; + } + + if (event.originalEventId === originalEventId) { + return true; + } + + if (anchorEvent && event.payload.title === anchorEvent.payload.title) { + return true; + } + + return false; + }); + + relatedEvents.forEach((event) => { + const dayBucket = alignToDay(event.timestamp, timezoneOffset); + + buckets.set(dayBucket, (buckets.get(dayBucket) || 0) + event.totalCount); + }); + + return [ + { + label: 'accepted', + data: Array.from(buckets.entries()).map(([timestamp, count]) => ({ + timestamp, + count, + })), + }, + ]; +} diff --git a/src/api/events/mocks/fetchDailyEventsPortion.mock.ts b/src/api/events/mocks/fetchDailyEventsPortion.mock.ts new file mode 100644 index 000000000..68bba9cf5 --- /dev/null +++ b/src/api/events/mocks/fetchDailyEventsPortion.mock.ts @@ -0,0 +1,65 @@ +import { getDemoEventsByProjectId } from '@/api/mock-db'; +import type { DailyEventsPortion } from '@hawk.so/types'; +import { MILLISECONDS_IN_SECOND, SECONDS_IN_DAY } from '@/utils/time'; +import { EventsSortOrder, type EventsFilters } from '@/types/events'; + +/** + * Mock: fetchDailyEventsPortion + * + * Returns daily events portion using centralized demo events + */ +export default function mockFetchDailyEventsPortion( + projectId?: string, + _nextCursor: unknown = null, + sort = EventsSortOrder.ByDate, + filters: EventsFilters = {}, + search = '' +): DailyEventsPortion { + const now_seconds = Math.floor(Date.now() / MILLISECONDS_IN_SECOND); + const dayTimestamp = Math.floor(now_seconds / SECONDS_IN_DAY) * SECONDS_IN_DAY; + + const normalizedSearch = String(search || '').trim().toLowerCase(); + + const filteredEvents = getDemoEventsByProjectId(projectId) + .filter((event) => { + if (typeof filters.starred === 'boolean' && event.marks.starred !== filters.starred) { + return false; + } + + if (typeof filters.resolved === 'boolean' && event.marks.resolved !== filters.resolved) { + return false; + } + + if (typeof filters.ignored === 'boolean' && event.marks.ignored !== filters.ignored) { + return false; + } + + if (normalizedSearch && !event.payload.title.toLowerCase().includes(normalizedSearch)) { + return false; + } + + return true; + }) + .sort((first, second) => { + if (sort === EventsSortOrder.ByCount) { + return second.totalCount - first.totalCount; + } + + if (sort === EventsSortOrder.ByAffectedUsers) { + return second.usersAffected - first.usersAffected; + } + + return second.timestamp - first.timestamp; + }); + + return { + nextCursor: null, + dailyEvents: filteredEvents.map(event => ({ + id: `daily-${event.id}`, + groupingTimestamp: dayTimestamp, + count: event.totalCount, + affectedUsers: event.usersAffected, + event, + })), + }; +} diff --git a/src/api/events/mocks/getEvent.mock.ts b/src/api/events/mocks/getEvent.mock.ts new file mode 100644 index 000000000..061d9796f --- /dev/null +++ b/src/api/events/mocks/getEvent.mock.ts @@ -0,0 +1,21 @@ +import { getDemoEventsByProjectId } from '@/api/mock-db'; +import type { HawkEvent } from '@hawk.so/types'; + +/** + * Mock: getEvent + * + * Returns a single event from DEMO_EVENTS by eventId + * Falls back to first event if not found + */ +export default function mockGetEvent( + projectId?: string, + eventId?: string, + originalEventId?: string +): HawkEvent | null { + const events = getDemoEventsByProjectId(projectId); + const event = events.find(item => + item.id === eventId || item.originalEventId === originalEventId + ) || events[0]; + + return event ?? null; +} diff --git a/src/api/events/mocks/getRepetitionsPortion.mock.ts b/src/api/events/mocks/getRepetitionsPortion.mock.ts new file mode 100644 index 000000000..bcb7147e6 --- /dev/null +++ b/src/api/events/mocks/getRepetitionsPortion.mock.ts @@ -0,0 +1,96 @@ +import { getDemoEventsByProjectId } from '@/api/mock-db'; +import type { HawkEvent } from '@hawk.so/types'; +import { MILLISECONDS_IN_HOUR, MILLISECONDS_IN_SECOND } from '@/utils/time'; + +const REPETITION_ID_OFFSET = 4; +const TWO = 2; +const THREE = 3; +const ONE_HOUR = Math.floor(MILLISECONDS_IN_HOUR / MILLISECONDS_IN_SECOND); +const TWO_HOURS = ONE_HOUR * TWO; +const THREE_HOURS = ONE_HOUR * THREE; +const NOW_SECONDS = Math.floor(Date.now() / MILLISECONDS_IN_SECOND); + +/** + * Mock: getRepetitionsPortion + * + * Returns repetitions of the first demo event with different browsers/timestamps + */ +export default function mockGetRepetitionsPortion( + projectId?: string, + originalEventId?: string +): { + data: { + project: { + event: { + repetitionsPortion: { + repetitions: HawkEvent[]; + nextCursor: null; + }; + }; + }; + }; + errors: unknown[]; +} { + const projectEvents = getDemoEventsByProjectId(projectId); + const baseEvent = projectEvents.find(event => event.originalEventId === originalEventId) || projectEvents[0]; + + if (!baseEvent) { + return { + data: { + project: { + event: { + repetitionsPortion: { + repetitions: [], + nextCursor: null, + }, + }, + }, + }, + errors: [], + }; + } + + // Create variations of the same event with different context + const variations = [ + { browser: 'Chrome 120.0.0 (Windows NT 10.0; Win64; x64)', + os: 'Windows 10', + timeOffset: 0 }, + { browser: 'Safari/537.36 (Macintosh; Intel Mac OS X 10_15_7)', + os: 'macOS 10.15.7', + timeOffset: -ONE_HOUR }, + { browser: 'Mobile Safari 17.2 (iPhone; CPU iPhone OS 17_2 like Mac OS X)', + os: 'iOS 17.2', + timeOffset: -TWO_HOURS }, + { browser: 'Firefox 122.0 (X11; Linux x86_64)', + os: 'Linux', + timeOffset: -THREE_HOURS }, + ]; + + const repetitions: HawkEvent[] = variations.map((variant, index) => ({ + ...baseEvent, + id: `507f1f77bcf86cd79943901${REPETITION_ID_OFFSET + index}`, + timestamp: NOW_SECONDS + variant.timeOffset, + payload: { + ...baseEvent.payload, + context: { + ...baseEvent.payload.context, + browser: variant.browser, + os: variant.os, + }, + }, + })); + + return { + data: { + project: { + event: { + repetitionsPortion: { + repetitions, + nextCursor: null, + }, + }, + }, + }, + errors: [], + }; +} diff --git a/src/api/events/mocks/toggleEventMark.mock.ts b/src/api/events/mocks/toggleEventMark.mock.ts new file mode 100644 index 000000000..5e25c1062 --- /dev/null +++ b/src/api/events/mocks/toggleEventMark.mock.ts @@ -0,0 +1,6 @@ +/** + * Mock response for toggleEventMark + */ +const mockToggleEventMark = true; + +export default mockToggleEventMark; diff --git a/src/api/events/mocks/visitEvent.mock.ts b/src/api/events/mocks/visitEvent.mock.ts new file mode 100644 index 000000000..f68a700ad --- /dev/null +++ b/src/api/events/mocks/visitEvent.mock.ts @@ -0,0 +1,6 @@ +/** + * Mock response for visitEvent + */ +const mockVisitEvent = true; + +export default mockVisitEvent; diff --git a/src/api/index.ts b/src/api/index.ts index d1fd039b7..440fd5a65 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -3,12 +3,39 @@ import axios from 'axios'; import { prepareFormData } from '@/api/utils'; import type { APIResponse } from '../types/api'; import { useErrorTracker } from '@/hawk'; +import { i18n } from '@/i18n'; +import { DEMO_ACCESS_TOKEN } from '@/composables/useDemo'; /** * Hawk API endpoint URL */ export const API_ENDPOINT: string = import.meta.env.VITE_API_ENDPOINT || ''; +/** + * Checks if the application is running in demo mode. + * + * This function determines demo mode by inspecting the Authorization header + * stored in axios defaults. In demo mode, a special access token ('demo-access-token') + * is used instead of a real user token. + * + * Why this approach: + * - Demo mode token is set globally in axios.defaults.headers.common.Authorization + * - This allows all API requests to use the demo token without passing it explicitly + * - The Authorization header format is typically "Bearer ", so we use includes() + * to check if the demo token is present anywhere in the header string + * - Type guard (typeof === 'string') ensures safe string operations, as the header + * could be undefined, string, or other types + * - This centralized approach makes it easy to check demo mode status anywhere in the app + * without additional state management + * + * @returns true if demo access token is present in the Authorization header, false otherwise + */ +function isDemoModeEnabled(): boolean { + const authHeader = axios.defaults.headers.common.Authorization; + + return typeof authHeader === 'string' && authHeader.includes(DEMO_ACCESS_TOKEN); +} + /** * A promise that will be resolved after the initialization request */ @@ -234,7 +261,11 @@ export async function call( */ if (response.errors && response.errors.length && allowErrors === false) { response.errors.forEach((error) => { - const err = new Error(error.message) as Error & { extensions?: Record }; + const isUnauthenticated = error.extensions && error.extensions.code === 'UNAUTHENTICATED'; + const message = isDemoModeEnabled() && isUnauthenticated + ? i18n.global.t('demo.functionUnavailable').toString() + : error.message; + const err = new Error(message) as Error & { extensions?: Record }; /** * Preserve extensions from GraphQL error diff --git a/src/api/mock-db/business-operations.ts b/src/api/mock-db/business-operations.ts new file mode 100644 index 000000000..8810a8ecb --- /dev/null +++ b/src/api/mock-db/business-operations.ts @@ -0,0 +1,71 @@ +/** + * Mock database: Business Operations (Payment History) + */ + +import type { BusinessOperation, PayloadOfWorkspacePlanPurchase } from '@/types/business-operation'; +import { BusinessOperationType } from '@/types/business-operation-type'; +import { BusinessOperationStatus } from '@/types/business-operation-status'; +import { DEMO_USER, DEMO_TEAM_MEMBERS } from './users'; +import { DEMO_WORKSPACE_ID } from './workspaces'; + +/** + * Demo business operations (payment history) + */ +export const DEMO_BUSINESS_OPERATIONS: BusinessOperation[] = [ + { + transactionId: 'txn-001-pro-current', + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Confirmed, + payload: { + workspace: { + id: DEMO_WORKSPACE_ID, + name: 'Demo Workspace', + } as any, + amount: 95000, + user: DEMO_USER, + }, + dtCreated: new Date('2026-02-15').getTime(), + }, + { + transactionId: 'txn-002-pro-prev', + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Confirmed, + payload: { + workspace: { + id: DEMO_WORKSPACE_ID, + name: 'Demo Workspace', + } as any, + amount: 95000, + user: DEMO_USER, + }, + dtCreated: new Date('2026-01-15').getTime(), + }, + { + transactionId: 'txn-003-pro-old', + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Confirmed, + payload: { + workspace: { + id: DEMO_WORKSPACE_ID, + name: 'Demo Workspace', + } as any, + amount: 95000, + user: DEMO_TEAM_MEMBERS[0], + }, + dtCreated: new Date('2025-12-15').getTime(), + }, + { + transactionId: 'txn-004-basic-old', + type: BusinessOperationType.WorkspacePlanPurchase, + status: BusinessOperationStatus.Confirmed, + payload: { + workspace: { + id: DEMO_WORKSPACE_ID, + name: 'Demo Workspace', + } as any, + amount: 9900, + user: DEMO_TEAM_MEMBERS[0], + }, + dtCreated: new Date('2025-11-15').getTime(), + }, +]; diff --git a/src/api/mock-db/charts.ts b/src/api/mock-db/charts.ts new file mode 100644 index 000000000..b8342e2d7 --- /dev/null +++ b/src/api/mock-db/charts.ts @@ -0,0 +1,92 @@ +/** + * Mock database: Charts + * + * Contains demo chart data for events and projects + */ + +import type { ChartLine } from '@hawk.so/types'; +import { + MILLISECONDS_IN_SECOND, + SECONDS_IN_DAY, + SIX_DAYS_AGO, + FIVE_DAYS_AGO, + FOUR_DAYS_AGO, + THREE_DAYS_AGO, + TWO_DAYS_AGO, + ONE_DAY_AGO +} from '@/utils/time'; + +const DAY = SECONDS_IN_DAY; +const NOW = Math.floor(Date.now() / MILLISECONDS_IN_SECOND); + +/** + * Demo chart data - used for both event and project charts + */ +export const DEMO_CHART_DATA: ChartLine[] = [ + { + label: 'accepted', + data: [ + { + timestamp: NOW - SIX_DAYS_AGO * DAY, + count: 175, + }, + { + timestamp: NOW - FIVE_DAYS_AGO * DAY, + count: 242, + }, + { + timestamp: NOW - FOUR_DAYS_AGO * DAY, + count: 198, + }, + { + timestamp: NOW - THREE_DAYS_AGO * DAY, + count: 215, + }, + { + timestamp: NOW - TWO_DAYS_AGO * DAY, + count: 321, + }, + { + timestamp: NOW - ONE_DAY_AGO * DAY, + count: 298, + }, + { + timestamp: NOW, + count: 335, + }, + ], + }, + { + label: 'rate-limited', + data: [ + { + timestamp: NOW - SIX_DAYS_AGO * DAY, + count: 105, + }, + { + timestamp: NOW - FIVE_DAYS_AGO * DAY, + count: 122, + }, + { + timestamp: NOW - FOUR_DAYS_AGO * DAY, + count: 18, + }, + { + timestamp: NOW - THREE_DAYS_AGO * DAY, + count: 2500, + }, + { + timestamp: NOW - TWO_DAYS_AGO * DAY, + count: 31, + }, + { + timestamp: NOW - ONE_DAY_AGO * DAY, + count: 128, + }, + { + timestamp: NOW, + count: 135, + }, + ], + }, +]; diff --git a/src/api/mock-db/events.ts b/src/api/mock-db/events.ts new file mode 100644 index 000000000..b18ff3244 --- /dev/null +++ b/src/api/mock-db/events.ts @@ -0,0 +1,442 @@ +/** + * Mock database: Events + * + * Contains demo error events with realistic data + */ + +import type { HawkEvent, User } from '@hawk.so/types'; +import { MILLISECONDS_IN_SECOND, SECONDS_IN_DAY } from '@/utils/time'; +import { DEMO_PROJECT_ID, DEMO_SECOND_PROJECT_ID } from './workspaces'; +import { DEMO_USER } from './users'; + +const NOW_SECONDS = Math.floor(Date.now() / MILLISECONDS_IN_SECOND); + +/** + * Helper to create realistic error event + * @param config + */ +function createDemoEvent(config: { + id: string; + originalEventId: string; + title: string; + type: string; + groupHash: string; + totalCount: number; + usersAffected: number; + timestamp?: number; + file?: string; + line?: number; + isStarred?: boolean; + isResolved?: boolean; + isIgnored?: boolean; + visitedBy?: User[]; + projectId?: string; +}): HawkEvent { + const { + id, + originalEventId, + title, + type, + groupHash, + totalCount, + usersAffected, + timestamp = Math.floor(Date.now() / MILLISECONDS_IN_SECOND), + file = 'src/store/user.ts', + line = 42, + isStarred = false, + isResolved = false, + isIgnored = false, + visitedBy = [], + projectId = DEMO_PROJECT_ID, + } = config; + + return { + id, + groupHash, + totalCount, + usersAffected, + visitedBy, + marks: { + resolved: isResolved, + starred: isStarred, + ignored: isIgnored, + }, + payload: { + title, + type, + backtrace: [ + { + file, + line, + column: 15, + function: 'getUserProfile', + arguments: ['userId'], + sourceCode: [ + { line: line - 2, + content: 'export const getUserProfile = (userId) => {' }, + { line: line - 1, + content: ' const user = store.getters.user;' }, + { line, + content: ' return user.profile.settings;' }, + { line: line + 1, + content: '};' }, + ], + }, + ], + get: { + userId: '507f1f77bcf86cd799439011', + format: 'json', + }, + post: {}, + headers: { + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', + 'Accept-Language': 'en-US,en;q=0.9', + }, + release: 'v1.2.3', + user: { + id: 'user-demo-001', + } as any, + context: { + browser: 'Chrome 120.0.0', + os: 'macOS 14.2.1', + screen: '1920x1080', + timezone: 'UTC+2', + language: 'en-US', + url: `http://localhost:8080/project/${projectId}`, + }, + addons: {} as any, + }, + catcherType: 'client/javascript', + repetitions: [], + assignee: undefined as any, + timestamp, + originalTimestamp: timestamp - SECONDS_IN_DAY, + originalEventId, + }; +} + +/** + * Demo events collection + */ +export const DEMO_EVENTS: HawkEvent[] = [ + createDemoEvent({ + id: '507f1f77bcf86cd799439011', + originalEventId: '507f1f77bcf86cd799439010', + title: 'TypeError: Cannot read property \'user\' of undefined', + type: 'TypeError', + groupHash: 'hash-507f1f77bcf86cd799439011', + totalCount: 42, + usersAffected: 8, + isStarred: true, + timestamp: NOW_SECONDS - 7 * 60, + }), + createDemoEvent({ + id: '507f191e810c19729de860ea', + originalEventId: '507f191e810c19729de860e9', + title: 'ReferenceError: apiKey is not defined', + type: 'ReferenceError', + groupHash: 'hash-507f191e810c19729de860ea', + totalCount: 18, + usersAffected: 5, + file: 'src/api/config.ts', + line: 15, + isResolved: true, + timestamp: NOW_SECONDS - 49 * 60, + }), + createDemoEvent({ + id: '507f1f77bcf86cd799439012', + originalEventId: '507f1f77bcf86cd799439013', + title: 'Network Error: Failed to fetch user data', + type: 'NetworkError', + groupHash: 'hash-507f1f77bcf86cd799439012', + totalCount: 7, + usersAffected: 3, + file: 'src/api/user/index.ts', + line: 28, + timestamp: NOW_SECONDS - 3 * 60 * 60, + }), + createDemoEvent({ + id: '507f1f77bcf86cd799439014', + originalEventId: '507f1f77bcf86cd799439015', + title: 'SyntaxError: Unexpected token < in JSON', + type: 'SyntaxError', + groupHash: 'hash-507f1f77bcf86cd799439014', + totalCount: 12, + usersAffected: 4, + file: 'src/utils/parser.ts', + line: 55, + isIgnored: true, + visitedBy: [DEMO_USER], + timestamp: NOW_SECONDS - 9 * 60 * 60, + }), + ...[ + { + title: 'TypeError: Cannot destructure property \"profile\" of null', + type: 'TypeError', + file: 'src/components/account/ProfileCard.vue', + line: 73, + totalCount: 26, + usersAffected: 7, + isStarred: true, + }, + { + title: 'Network Error: request timeout while loading dashboard', + type: 'NetworkError', + file: 'src/api/projects/index.js', + line: 114, + totalCount: 31, + usersAffected: 11, + }, + { + title: 'RateLimitError: Too many requests to /events endpoint', + type: 'RateLimitError', + file: 'src/api/events/index.ts', + line: 87, + totalCount: 9, + usersAffected: 5, + isIgnored: true, + }, + { + title: 'ReferenceError: config is not defined in init script', + type: 'ReferenceError', + file: 'src/main.ts', + line: 22, + totalCount: 14, + usersAffected: 4, + }, + { + title: 'RangeError: Maximum call stack size exceeded in serializer', + type: 'RangeError', + file: 'src/utils/serializer.ts', + line: 129, + totalCount: 19, + usersAffected: 6, + }, + { + title: 'TypeError: Failed to execute \"appendChild\" on \"Node\"', + type: 'TypeError', + file: 'src/components/modals/BaseModal.vue', + line: 101, + totalCount: 23, + usersAffected: 9, + isResolved: true, + }, + { + title: 'RateLimitError: Ingestion quota exceeded for project token', + type: 'RateLimitError', + file: 'src/api/index.ts', + line: 438, + totalCount: 12, + usersAffected: 3, + }, + { + title: 'SyntaxError: Unexpected end of JSON input', + type: 'SyntaxError', + file: 'src/store/modules/events/index.ts', + line: 241, + totalCount: 8, + usersAffected: 4, + isResolved: true, + }, + ].flatMap((template, index) => { + const minuteOffsets = [ + 75, + 115, + 170, + 260, + 360, + 510, + 720, + 960, + 1410, + 1890, + 2520, + 3360, + 4290, + 5370, + 6960, + 8400, + 10080, + 12180, + 14640, + 17220, + 20160, + 23040, + 25920, + 28800, + 31680, + 34560, + 37440, + 40320, + 41760, + ]; + + return minuteOffsets + .filter((_, offsetIndex) => offsetIndex % 8 === index) + .map((offsetMinutes, offsetIndex) => { + const sequence = index * 10 + offsetIndex; + const idSuffix = String(1000 + sequence).padStart(4, '0'); + const originalSuffix = String(2000 + sequence).padStart(4, '0'); + const totalCount = template.totalCount + (offsetIndex % 3) * 4; + const usersAffected = Math.max(1, Math.min(totalCount, template.usersAffected + (offsetIndex % 2))); + + return createDemoEvent({ + id: `507f1f77bcf86cd79944${idSuffix}`, + originalEventId: `507f1f77bcf86cd79945${originalSuffix}`, + title: template.title, + type: template.type, + groupHash: `hash-507f1f77bcf86cd79944${idSuffix}`, + totalCount, + usersAffected, + file: template.file, + line: template.line, + isStarred: Boolean(template.isStarred), + isResolved: Boolean(template.isResolved), + isIgnored: Boolean(template.isIgnored), + timestamp: NOW_SECONDS - offsetMinutes * 60, + }); + }); + }), + ...[ + { + title: 'TypeError: undefined is not an object (evaluating \"navigation.state.routes\")', + type: 'TypeError', + file: 'src/mobile/navigation/router.ts', + line: 143, + totalCount: 34, + usersAffected: 16, + isStarred: true, + }, + { + title: 'Network Error: timeout reached while syncing offline queue', + type: 'NetworkError', + file: 'src/mobile/offline/syncQueue.ts', + line: 211, + totalCount: 56, + usersAffected: 27, + }, + { + title: 'RateLimitError: mobile ingest throughput exceeded', + type: 'RateLimitError', + file: 'src/mobile/telemetry/send.ts', + line: 79, + totalCount: 22, + usersAffected: 8, + isIgnored: true, + }, + { + title: 'ReferenceError: pushToken is not defined in notifications bootstrap', + type: 'ReferenceError', + file: 'src/mobile/push/bootstrap.ts', + line: 41, + totalCount: 18, + usersAffected: 10, + isResolved: true, + }, + { + title: 'SyntaxError: JSON Parse error: Unexpected EOF', + type: 'SyntaxError', + file: 'src/mobile/storage/restoreState.ts', + line: 63, + totalCount: 27, + usersAffected: 12, + }, + { + title: 'RangeError: Invalid time value while formatting release date', + type: 'RangeError', + file: 'src/mobile/utils/date.ts', + line: 58, + totalCount: 16, + usersAffected: 7, + }, + ].flatMap((template, index) => { + const minuteOffsets = [ + 12, + 19, + 26, + 34, + 43, + 57, + 69, + 85, + 101, + 130, + 164, + 211, + 269, + 345, + 447, + 576, + 743, + 957, + 1242, + 1610, + 2098, + 2730, + 3520, + 4560, + 5840, + 7440, + 9360, + 11760, + 14640, + 18000, + 21600, + 25200, + 28800, + 32400, + 36000, + 39600, + 41700, + ]; + + return minuteOffsets + .filter((_, offsetIndex) => offsetIndex % 6 === index) + .map((offsetMinutes, offsetIndex) => { + const sequence = index * 20 + offsetIndex; + const idSuffix = String(3000 + sequence).padStart(4, '0'); + const originalSuffix = String(4000 + sequence).padStart(4, '0'); + const totalCount = template.totalCount + (offsetIndex % 4) * 5; + const usersAffected = Math.max(1, Math.min(totalCount, template.usersAffected + (offsetIndex % 3))); + + return createDemoEvent({ + id: `607f1f77bcf86cd79944${idSuffix}`, + originalEventId: `607f1f77bcf86cd79945${originalSuffix}`, + title: template.title, + type: template.type, + groupHash: `hash-607f1f77bcf86cd79944${idSuffix}`, + totalCount, + usersAffected, + file: template.file, + line: template.line, + isStarred: Boolean(template.isStarred), + isResolved: Boolean(template.isResolved), + isIgnored: Boolean(template.isIgnored), + timestamp: NOW_SECONDS - offsetMinutes * 60, + projectId: DEMO_SECOND_PROJECT_ID, + }); + }); + }), +]; + +/** + * Returns events for specific project in demo mode + * @param projectId + */ +export function getDemoEventsByProjectId(projectId?: string): HawkEvent[] { + if (!projectId) { + return DEMO_EVENTS; + } + + const projectPath = `/project/${projectId}`; + + return DEMO_EVENTS.filter(event => event.payload.context.url.includes(projectPath)); +} + +/** + * Get event by ID + * @param id + */ +export function getDemoEventById(id: string): HawkEvent | undefined { + return DEMO_EVENTS.find(event => event.id === id); +} diff --git a/src/api/mock-db/index.ts b/src/api/mock-db/index.ts new file mode 100644 index 000000000..a40d8cf89 --- /dev/null +++ b/src/api/mock-db/index.ts @@ -0,0 +1,12 @@ +/** + * Mock Database - Central Export + * + * Re-exports all mock data from subdirectories + */ + +export * from './users'; +export * from './workspaces'; +export * from './events'; +export * from './charts'; +export * from './releases'; +export * from './business-operations'; diff --git a/src/api/mock-db/releases.ts b/src/api/mock-db/releases.ts new file mode 100644 index 000000000..bc36c01ee --- /dev/null +++ b/src/api/mock-db/releases.ts @@ -0,0 +1,99 @@ +/** + * Mock database: Releases + * + * Contains demo release data for projects + */ + +import type { ReleaseData, ReleaseDetails } from '@hawk.so/types'; +import { + MILLISECONDS_IN_SECOND, + SECONDS_IN_HOUR, + SECONDS_IN_DAY, + ONE_DAY_AGO, + ONE_WEEK_AGO, + TWO_WEEKS_AGO, + THREE_WEEKS_AGO +} from '@/utils/time'; + +const NOW_SECONDS = Math.floor(Date.now() / MILLISECONDS_IN_SECOND); +const TWO_HOURS_SECONDS = SECONDS_IN_HOUR * 2; + +/** + * Demo releases list + */ +export const DEMO_RELEASES: ReleaseData[] = [ + { + release: 'v2.5.0', + timestamp: NOW_SECONDS - SECONDS_IN_DAY * ONE_DAY_AGO, + newEventsCount: 3, + commitsCount: 12, + filesCount: 8, + }, + { + release: 'v2.4.1', + timestamp: NOW_SECONDS - SECONDS_IN_DAY * ONE_WEEK_AGO, + newEventsCount: 1, + commitsCount: 5, + filesCount: 3, + }, + { + release: 'v2.4.0', + timestamp: NOW_SECONDS - SECONDS_IN_DAY * TWO_WEEKS_AGO, + newEventsCount: 2, + commitsCount: 20, + filesCount: 15, + }, + { + release: 'v2.3.2', + timestamp: NOW_SECONDS - SECONDS_IN_DAY * THREE_WEEKS_AGO, + newEventsCount: 0, + commitsCount: 8, + filesCount: 5, + }, +]; + +/** + * Demo release details (for v2.5.0) + */ +export const DEMO_RELEASE_DETAILS: ReleaseDetails = { + release: 'v2.5.0', + timestamp: NOW_SECONDS - SECONDS_IN_DAY * ONE_DAY_AGO, + commits: [ + { + hash: 'abc123def', + message: 'Fix critical bug in payment processing', + author: 'Demo Developer', + timestamp: NOW_SECONDS - SECONDS_IN_DAY, + }, + { + hash: 'def456ghi', + message: 'Update dependencies', + author: 'Demo Developer', + timestamp: NOW_SECONDS - SECONDS_IN_DAY - SECONDS_IN_HOUR, + }, + { + hash: 'ghi789jkl', + message: 'Improve error handling', + author: 'Demo Developer', + timestamp: NOW_SECONDS - SECONDS_IN_DAY - TWO_HOURS_SECONDS, + }, + ], + files: [ + { + path: 'src/payment/processor.ts', + additions: 15, + deletions: 8, + }, + { + path: 'package.json', + additions: 3, + deletions: 3, + }, + { + path: 'src/utils/errorHandler.ts', + additions: 22, + deletions: 5, + }, + ], + newEventsCount: 3, +}; diff --git a/src/api/mock-db/users.ts b/src/api/mock-db/users.ts new file mode 100644 index 000000000..92a2798c7 --- /dev/null +++ b/src/api/mock-db/users.ts @@ -0,0 +1,35 @@ +/** + * Mock database: Users + * + * Contains demo user data + */ + +import type { User } from '@hawk.so/types'; + +/** + * Demo user account + */ +export const DEMO_USER: User = { + id: 'user-demo-001', + email: 'demo@hawk.so', + name: 'Demo User', + image: 'https://ui-avatars.com/api/?name=John+Dev&background=4ECDC4&color=fff', +}; + +/** + * Additional team members for demo workspace + */ +export const DEMO_TEAM_MEMBERS: User[] = [ + { + id: 'user-dev-001', + email: 'john@example.com', + name: 'John Developer', + image: 'https://ui-avatars.com/api/?name=John+Dev&background=4ECDC4&color=fff', + }, + { + id: 'user-lead-001', + email: 'sarah@example.com', + name: 'Sarah Team Lead', + image: 'https://ui-avatars.com/api/?name=Sarah+Lead&background=95E1D3&color=fff', + }, +]; diff --git a/src/api/mock-db/workspaces.ts b/src/api/mock-db/workspaces.ts new file mode 100644 index 000000000..69888d313 --- /dev/null +++ b/src/api/mock-db/workspaces.ts @@ -0,0 +1,167 @@ +/** + * Mock database: Workspaces & Projects + * + * Contains demo workspace and project data with proper references + */ + +import type { Workspace, Project } from '@hawk.so/types'; +import { ReceiveTypes } from '@/types/project-notifications'; +import { DEMO_USER, DEMO_TEAM_MEMBERS } from './users'; + +/** + * Demo workspace ID (used across the app) + */ +export const DEMO_WORKSPACE_ID = '6213b6a01e6281087467cc7a'; + +/** + * Demo project ID (used across the app) + */ +export const DEMO_PROJECT_ID = '6215743cf3ff6b80215cb183'; + +/** + * Second demo project ID + */ +export const DEMO_SECOND_PROJECT_ID = '7215743cf3ff6b80215cb284'; + +/** + * Demo workspace with team and plan + */ +export const DEMO_WORKSPACE: Workspace = { + id: DEMO_WORKSPACE_ID, + name: 'Demo Workspace', + description: 'This is a demo workspace showcasing Hawk error tracking', + image: 'https://static.hawk.so/fb59c4d7-db38-46d9-936e-c0d468ab1ea2.png', + inviteHash: 'demo-invite-hash', + team: [ + { + id: 'member-demo-001', + user: DEMO_USER, + isAdmin: true, + }, + { + id: 'member-dev-001', + user: DEMO_TEAM_MEMBERS[0], + isAdmin: false, + }, + { + id: 'member-lead-001', + user: DEMO_TEAM_MEMBERS[1], + isAdmin: true, + }, + ], + plan: { + id: 'free', + name: 'Бесплатный', + monthlyCharge: 0, + monthlyChargeCurrency: 'RUB', + eventsLimit: 1000, + }, + lastChargeDate: new Date('2026-01-15'), + isDebug: true, + isBlocked: false, +}; + +/** + * Demo project within workspace + */ +export const DEMO_PROJECT: Project = { + id: DEMO_PROJECT_ID, + workspaceId: DEMO_WORKSPACE_ID, + token: `hawk_${DEMO_PROJECT_ID}_demo_token`, + name: 'Production App', + uidAdded: DEMO_USER, + unreadCount: 5, + description: 'Production environment error tracker', + image: 'https://ui-avatars.com/api/?name=Prod+App&background=4ECDC4&color=fff', + notifications: [ + { + id: 'notif-001', + uidAdded: DEMO_USER.id, + channels: { + email: { + endpoint: 'demo@hawk.so', + isEnabled: true, + }, + slack: { + endpoint: 'https://hooks.slack.com/demo', + isEnabled: true, + }, + telegram: { + endpoint: '', + isEnabled: false, + }, + }, + whatToReceive: ReceiveTypes.SEEN_MORE, + isEnabled: true, + }, + ], + eventGroupingPatterns: [ + { + id: 'pattern-001', + pattern: 'TypeError.*Cannot read.*undefined', + }, + { + id: 'pattern-002', + pattern: 'ReferenceError.*is not defined', + }, + ], + rateLimitSettings: { + N: 100000, + T: 86400, + }, +}; + +/** + * Second demo project with separate stream of events + */ +export const DEMO_SECOND_PROJECT: Project = { + id: DEMO_SECOND_PROJECT_ID, + workspaceId: DEMO_WORKSPACE_ID, + token: `hawk_${DEMO_SECOND_PROJECT_ID}_demo_token`, + name: 'Mobile App Beta', + uidAdded: DEMO_TEAM_MEMBERS[0], + unreadCount: 11, + description: 'Beta environment with aggressive rollout and feature flags', + image: 'https://ui-avatars.com/api/?name=Mobile+Beta&background=FF6B6B&color=fff', + notifications: [ + { + id: 'notif-201', + uidAdded: DEMO_USER.id, + channels: { + email: { + endpoint: 'beta-alerts@hawk.so', + isEnabled: true, + }, + slack: { + endpoint: 'https://hooks.slack.com/beta-demo', + isEnabled: true, + }, + telegram: { + endpoint: '@hawk_beta_alerts', + isEnabled: true, + }, + }, + whatToReceive: ReceiveTypes.ONLY_NEW, + isEnabled: true, + }, + ], + eventGroupingPatterns: [ + { + id: 'pattern-201', + pattern: 'NetworkError.*timeout.*', + }, + { + id: 'pattern-202', + pattern: 'TypeError.*undefined is not an object', + }, + ], + rateLimitSettings: { + N: 50000, + T: 3600, + }, +}; + +/** + * Demo projects collection + */ +export const DEMO_PROJECTS: Project[] = [DEMO_PROJECT, DEMO_SECOND_PROJECT]; diff --git a/src/api/projects/index.js b/src/api/projects/index.js index 4ce26120f..00ae41a36 100644 --- a/src/api/projects/index.js +++ b/src/api/projects/index.js @@ -20,6 +20,7 @@ import { MUTATION_UPDATE_TASK_MANAGER_SETTINGS } from './queries'; import * as api from '../index.ts'; +import { withDemoMock } from '@/utils/withDemoMock.ts'; /** * Create project and returns its id @@ -128,9 +129,18 @@ export async function removeProject(projectId) { * @param {string} projectId - project ID * @returns {Promise} */ -export async function updateLastProjectVisit(projectId) { - return (await api.callOld(MUTATION_UPDATE_LAST_VISIT, { projectId })).setLastProjectVisit; -} +/** + * Update last project visit timestamp + * + * @param {string} projectId - id of the project + * @returns {Promise} - success status + */ +export const updateLastProjectVisit = withDemoMock( + async function updateLastProjectVisit(projectId) { + return (await api.callOld(MUTATION_UPDATE_LAST_VISIT, { projectId })).setLastProjectVisit; + }, + '/src/api/projects/mocks/updateLastProjectVisit.mock.ts' +); /** * Send request for creation new project notifications rule @@ -238,22 +248,34 @@ export async function toggleEnabledStateOfProjectNotificationsRule(payload) { * @param {number} timezoneOffset - user's local timezone offset * @returns {Promise>} */ -export async function fetchChartData(projectId, startDate, endDate, groupBy, timezoneOffset) { - const response = await api.call(QUERY_CHART_DATA, { - projectId, - startDate, - endDate, - groupBy, - timezoneOffset, - }, undefined, { +/** + * Fetch chart data for project overview + * + * @param {string} projectId - id of the project + * @param {string} startDate - start date in ISO format + * @param {string} endDate - end date in ISO format + * @param {string} groupBy - grouping period (day, week, month) + * @param {number} timezoneOffset - timezone offset in minutes + * @returns {Promise} - chart data response + */ +export const fetchChartData = withDemoMock( + async function fetchChartData(projectId, startDate, endDate, groupBy, timezoneOffset) { + const response = await api.call(QUERY_CHART_DATA, { + projectId, + startDate, + endDate, + groupBy, + timezoneOffset, + }, undefined, { /** * Allow errors to be returned in response for handling in store/component */ - allowErrors: true, - }); - - return response; -} + allowErrors: true, + }); + return response; + }, + '/src/api/projects/mocks/fetchChartData.mock.ts' +); /** * Fetch project releases @@ -261,15 +283,17 @@ export async function fetchChartData(projectId, startDate, endDate, groupBy, tim * @param {string} projectId - id of the project to fetch releases * @returns {Promise>} - list of releases with unique events count, commits count and files count */ -export async function fetchProjectReleases(projectId) { - const response = await api.call(QUERY_PROJECT_RELEASES, { projectId }); - - if (response.errors?.length) { - response.errors.forEach(console.error); - } - - return response.data.project.releases; -} +export const fetchProjectReleases = withDemoMock( + async function fetchProjectReleases(projectId) { + const response = await api.call(QUERY_PROJECT_RELEASES, { projectId }); + + if (response.errors?.length) { + response.errors.forEach(console.error); + } + return response.data.project.releases; + }, + '/src/api/projects/mocks/fetchProjectReleases.mock.ts' +); /** * Fetch specific release details @@ -278,23 +302,25 @@ export async function fetchProjectReleases(projectId) { * @param {string} release * @returns {Promise} */ -export async function fetchProjectReleaseDetails(projectId, release) { - const response = await api.call(QUERY_PROJECT_RELEASE_DETAILS, { projectId, - release }); +export const fetchProjectReleaseDetails = withDemoMock( + async function fetchProjectReleaseDetails(projectId, release) { + const response = await api.call(QUERY_PROJECT_RELEASE_DETAILS, { projectId, + release }); - if (response.errors?.length) { + if (response.errors?.length) { /** * Throw error if release not found or other API errors */ - const error = new Error(response.errors[0].message); - - error.name = response.errors[0].extensions?.code || 'API_ERROR'; + const error = new Error(response.errors[0].message); - throw error; - } + error.name = response.errors[0].extensions?.code || 'API_ERROR'; - return response.data.project.releaseDetails; -} + throw error; + } + return response.data.project.releaseDetails; + }, + '/src/api/projects/mocks/fetchProjectReleaseDetails.mock.ts' +); /** * Send request for unsubscribing from notifications diff --git a/src/api/projects/mocks/fetchChartData.mock.ts b/src/api/projects/mocks/fetchChartData.mock.ts new file mode 100644 index 000000000..b6cd5f446 --- /dev/null +++ b/src/api/projects/mocks/fetchChartData.mock.ts @@ -0,0 +1,141 @@ +import { getDemoEventsByProjectId } from '@/api/mock-db'; +import type { ChartLine } from '@hawk.so/types'; + +const SECONDS_IN_MINUTE = 60; +const SECONDS_IN_DAY = 24 * 60 * 60; + +function toUnixSeconds(value: string | number | undefined, fallback: number): number { + if (typeof value === 'number') { + return value > 1e12 ? Math.floor(value / 1000) : Math.floor(value); + } + + if (typeof value === 'string' && value.trim() !== '') { + const numeric = Number(value); + + if (Number.isFinite(numeric)) { + return numeric > 1e12 ? Math.floor(numeric / 1000) : Math.floor(numeric); + } + + const parsed = Date.parse(value); + + if (!Number.isNaN(parsed)) { + return Math.floor(parsed / 1000); + } + } + + return fallback; +} + +function getBucketStart(timestamp: number, bucketSizeSeconds: number, timezoneOffset = 0): number { + const shiftedTimestamp = timestamp - timezoneOffset * SECONDS_IN_MINUTE; + + return Math.floor(shiftedTimestamp / bucketSizeSeconds) * bucketSizeSeconds + timezoneOffset * SECONDS_IN_MINUTE; +} + +function isRateLimitedEvent(event: { payload?: { type?: string; title?: string } }): boolean { + const type = event.payload?.type?.toLowerCase() || ''; + const title = event.payload?.title?.toLowerCase() || ''; + + return type.includes('ratelimit') || title.includes('rate limit') || title.includes('rate-limit'); +} + +/** + * Mock: fetchChartData (projects) + * + * Returns chart data for project overview from mock-db + */ +export default function mockFetchChartData( + projectId: string, + startDate: string, + endDate: string, + groupBy: number, + timezoneOffset = 0 +): { + data: { project: { chartData: ChartLine[] } }; + errors: unknown[]; +} { + const nowSeconds = Math.floor(Date.now() / 1000); + const safeGroupBy = Math.max(1, Number(groupBy) || 60); + const bucketSizeSeconds = safeGroupBy * SECONDS_IN_MINUTE; + const startTimestamp = toUnixSeconds(startDate, nowSeconds - SECONDS_IN_DAY); + const endTimestamp = toUnixSeconds(endDate, nowSeconds); + + if (startTimestamp > endTimestamp) { + return { + data: { + project: { + chartData: [ + { + label: 'accepted', + data: [], + }, + { + label: 'rate-limited', + data: [], + }, + ], + }, + }, + errors: [], + }; + } + + const firstBucket = getBucketStart(startTimestamp, bucketSizeSeconds, timezoneOffset); + const lastBucket = getBucketStart(endTimestamp, bucketSizeSeconds, timezoneOffset); + const acceptedByBucket = new Map(); + const rateLimitedByBucket = new Map(); + + for (let bucket = firstBucket; bucket <= lastBucket; bucket += bucketSizeSeconds) { + acceptedByBucket.set(bucket, 0); + rateLimitedByBucket.set(bucket, 0); + } + + const eventsInRange = getDemoEventsByProjectId(projectId).filter(event => + event.timestamp >= startTimestamp && event.timestamp <= endTimestamp + ); + + eventsInRange.forEach(event => { + const bucket = getBucketStart(event.timestamp, bucketSizeSeconds, timezoneOffset); + + if (!acceptedByBucket.has(bucket)) { + return; + } + + const acceptedCount = acceptedByBucket.get(bucket) || 0; + + acceptedByBucket.set(bucket, acceptedCount + event.totalCount); + + if (isRateLimitedEvent(event)) { + const rateLimitedCount = rateLimitedByBucket.get(bucket) || 0; + + rateLimitedByBucket.set(bucket, rateLimitedCount + event.totalCount); + } + }); + + const acceptedLine = Array.from(acceptedByBucket.entries()).map(([timestamp, count]) => ({ + timestamp, + count, + })); + const rateLimitedLine = Array.from(rateLimitedByBucket.entries()).map(([timestamp, count]) => ({ + timestamp, + count, + })); + + return { + data: { + project: { + chartData: [ + { + label: 'accepted', + data: acceptedLine, + }, + { + label: 'rate-limited', + data: rateLimitedLine, + }, + ], + }, + }, + errors: [], + }; +} diff --git a/src/api/projects/mocks/fetchProjectReleaseDetails.mock.ts b/src/api/projects/mocks/fetchProjectReleaseDetails.mock.ts new file mode 100644 index 000000000..f5acd43e5 --- /dev/null +++ b/src/api/projects/mocks/fetchProjectReleaseDetails.mock.ts @@ -0,0 +1,11 @@ +import { DEMO_RELEASE_DETAILS } from '@/api/mock-db'; +import type { ReleaseDetails } from '@hawk.so/types'; + +/** + * Mock: fetchProjectReleaseDetails + * + * Returns detailed release information from mock-db + */ +export default function mockFetchProjectReleaseDetails(): ReleaseDetails { + return DEMO_RELEASE_DETAILS; +} diff --git a/src/api/projects/mocks/fetchProjectReleases.mock.ts b/src/api/projects/mocks/fetchProjectReleases.mock.ts new file mode 100644 index 000000000..736b3ee03 --- /dev/null +++ b/src/api/projects/mocks/fetchProjectReleases.mock.ts @@ -0,0 +1,11 @@ +import { DEMO_RELEASES } from '@/api/mock-db'; +import type { ReleaseData } from '@hawk.so/types'; + +/** + * Mock: fetchProjectReleases + * + * Returns list of releases from mock-db + */ +export default function mockFetchProjectReleases(): ReleaseData[] { + return DEMO_RELEASES; +} diff --git a/src/api/projects/mocks/updateLastProjectVisit.mock.ts b/src/api/projects/mocks/updateLastProjectVisit.mock.ts new file mode 100644 index 000000000..3eb626af4 --- /dev/null +++ b/src/api/projects/mocks/updateLastProjectVisit.mock.ts @@ -0,0 +1,9 @@ +/** + * Mock for updateLastProjectVisit API function + * Returns success status for updating last project visit timestamp + * @param projectId + */ +export default async function updateLastProjectVisitMock(projectId: string): Promise { + // Always return true in demo mode - no actual tracking needed + return true; +} diff --git a/src/api/user/index.js b/src/api/user/index.js index 6b4f7eecd..c046af34a 100644 --- a/src/api/user/index.js +++ b/src/api/user/index.js @@ -12,6 +12,7 @@ import { } from './queries'; import * as api from '../index.ts'; import { validateUtmParams } from '../../components/utils/utm/utm.ts'; +import { withDemoMock } from '../../utils/withDemoMock.ts'; /** * @typedef {object} TokensPair @@ -83,9 +84,12 @@ export async function refreshTokens(refreshToken) { * * @returns {Promise>} */ -export async function fetchCurrentUser() { - return await api.call(QUERY_CURRENT_USER, {}, undefined, { allowErrors: true }); -} +export const fetchCurrentUser = withDemoMock( + async function fetchCurrentUser() { + return await api.call(QUERY_CURRENT_USER, {}, undefined, { allowErrors: true }); + }, + '/src/api/user/mocks/fetchCurrentUser.mock.ts' +); /** * Update user profile @@ -121,39 +125,62 @@ export async function changePassword(oldPassword, newPassword) { * * @returns {Promise>} */ -export async function fetchNotificationsSettings() { - return (await api.callOld(QUERY_CURRENT_USER_WITH_NOTIFICATIONS)).me; +async function fetchNotificationsSettingsRequest() { + const response = await api.call(QUERY_CURRENT_USER_WITH_NOTIFICATIONS); + + return response.data.me; } +export const fetchNotificationsSettings = withDemoMock( + fetchNotificationsSettingsRequest, + '/src/api/user/mocks/fetchNotificationsSettings.mock.ts' +); + /** * Change notifications channel settings * * @param {UserNotificationsChannels} payload - new channel settings * @returns {Promise<{notifications: UserNotifications}>} */ -export async function updateNotificationsChannel(payload) { - return (await api.callOld(MUTATION_CHANGE_USER_NOTIFICATIONS_CHANNEL, { +async function updateNotificationsChannelRequest(payload) { + const response = await api.call(MUTATION_CHANGE_USER_NOTIFICATIONS_CHANNEL, { input: payload, - })).changeUserNotificationsChannel; + }); + + return response.data.changeUserNotificationsChannel; } +export const updateNotificationsChannel = withDemoMock( + updateNotificationsChannelRequest, + '/src/api/user/mocks/updateNotificationsChannel.mock.ts' +); + /** * Change notifications receive type * * @param {UserNotificationsReceiveTypesConfig} payload - Receive Type with its is-enabled state * @returns {Promise<{notifications: UserNotifications}>} */ -export async function updateNotificationsReceiveType(payload) { - return (await api.callOld(MUTATION_CHANGE_USER_NOTIFICATIONS_RECEIVE_TYPE, { +async function updateNotificationsReceiveTypeRequest(payload) { + const response = await api.call(MUTATION_CHANGE_USER_NOTIFICATIONS_RECEIVE_TYPE, { input: payload, - })).changeUserNotificationsReceiveType; + }); + + return response.data.changeUserNotificationsReceiveType; } +export const updateNotificationsReceiveType = withDemoMock( + updateNotificationsReceiveTypeRequest, + '/src/api/user/mocks/updateNotificationsReceiveType.mock.ts' +); + /** * Fetches user's bank cards for one-click payments * * @returns {Promise>} */ export async function fetchBankCards() { - return (await api.callOld(QUERY_BANK_CARDS)).me.bankCards || []; + const response = await api.call(QUERY_BANK_CARDS); + + return response.data.me?.bankCards || []; } diff --git a/src/api/user/mocks/fetchCurrentUser.mock.ts b/src/api/user/mocks/fetchCurrentUser.mock.ts new file mode 100644 index 000000000..c8352c7be --- /dev/null +++ b/src/api/user/mocks/fetchCurrentUser.mock.ts @@ -0,0 +1,20 @@ +/** + * Mock: fetchCurrentUser + * + * Returns the demo user account + */ + +import { DEMO_USER } from '@/api/mock-db'; +import type { CurrentUser } from '@hawk.so/types'; + +export default function mockFetchCurrentUser(): { + data: { me: CurrentUser }; + errors: unknown[]; +} { + return { + data: { + me: DEMO_USER, + }, + errors: [], + }; +} diff --git a/src/api/user/mocks/fetchNotificationsSettings.mock.ts b/src/api/user/mocks/fetchNotificationsSettings.mock.ts new file mode 100644 index 000000000..ae671bc12 --- /dev/null +++ b/src/api/user/mocks/fetchNotificationsSettings.mock.ts @@ -0,0 +1,7 @@ +import { getDemoNotifications } from './notificationsState'; + +export default function mockFetchNotificationsSettings() { + return { + notifications: getDemoNotifications(), + }; +} diff --git a/src/api/user/mocks/notificationsState.ts b/src/api/user/mocks/notificationsState.ts new file mode 100644 index 000000000..7917c9b20 --- /dev/null +++ b/src/api/user/mocks/notificationsState.ts @@ -0,0 +1,95 @@ +type DemoNotifications = { + channels: { + email: { + isEnabled: boolean; + endpoint: string; + }; + webPush: { + isEnabled: boolean; + endpoint: string; + }; + desktopPush: { + isEnabled: boolean; + endpoint: string; + }; + }; + whatToReceive: { + IssueAssigning: boolean; + WeeklyDigest: boolean; + SystemMessages: boolean; + }; +}; + +const initialNotifications: DemoNotifications = { + channels: { + email: { + isEnabled: true, + endpoint: 'demo@hawk.so', + }, + webPush: { + isEnabled: false, + endpoint: '', + }, + desktopPush: { + isEnabled: false, + endpoint: '', + }, + }, + whatToReceive: { + IssueAssigning: true, + WeeklyDigest: true, + SystemMessages: true, + }, +}; + +let demoNotifications: DemoNotifications = { + channels: { + ...initialNotifications.channels, + }, + whatToReceive: { + ...initialNotifications.whatToReceive, + }, +}; + +function cloneNotifications(): DemoNotifications { + return { + channels: { + email: { ...demoNotifications.channels.email }, + webPush: { ...demoNotifications.channels.webPush }, + desktopPush: { ...demoNotifications.channels.desktopPush }, + }, + whatToReceive: { + ...demoNotifications.whatToReceive, + }, + }; +} + +export function getDemoNotifications(): DemoNotifications { + return cloneNotifications(); +} + +export function updateDemoNotificationChannels(input: Partial): DemoNotifications { + demoNotifications = { + ...demoNotifications, + channels: { + ...demoNotifications.channels, + ...input, + }, + }; + + return cloneNotifications(); +} + +export function updateDemoNotificationReceiveTypes( + input: Partial +): DemoNotifications { + demoNotifications = { + ...demoNotifications, + whatToReceive: { + ...demoNotifications.whatToReceive, + ...input, + }, + }; + + return cloneNotifications(); +} diff --git a/src/api/user/mocks/updateNotificationsChannel.mock.ts b/src/api/user/mocks/updateNotificationsChannel.mock.ts new file mode 100644 index 000000000..b6fe91894 --- /dev/null +++ b/src/api/user/mocks/updateNotificationsChannel.mock.ts @@ -0,0 +1,15 @@ +import { updateDemoNotificationChannels } from './notificationsState'; + +type NotificationsChannelInput = { + input?: { + email?: { endpoint: string; isEnabled: boolean }; + webPush?: { endpoint: string; isEnabled: boolean }; + desktopPush?: { endpoint: string; isEnabled: boolean }; + }; +}; + +export default function mockUpdateNotificationsChannel(payload: NotificationsChannelInput = {}) { + return { + notifications: updateDemoNotificationChannels(payload.input || {}), + }; +} diff --git a/src/api/user/mocks/updateNotificationsReceiveType.mock.ts b/src/api/user/mocks/updateNotificationsReceiveType.mock.ts new file mode 100644 index 000000000..eadae40fb --- /dev/null +++ b/src/api/user/mocks/updateNotificationsReceiveType.mock.ts @@ -0,0 +1,15 @@ +import { updateDemoNotificationReceiveTypes } from './notificationsState'; + +type NotificationReceiveInput = { + input?: { + IssueAssigning?: boolean; + WeeklyDigest?: boolean; + SystemMessages?: boolean; + }; +}; + +export default function mockUpdateNotificationsReceiveType(payload: NotificationReceiveInput = {}) { + return { + notifications: updateDemoNotificationReceiveTypes(payload.input || {}), + }; +} diff --git a/src/api/workspaces/index.ts b/src/api/workspaces/index.ts index 1a4b63422..06ad33273 100644 --- a/src/api/workspaces/index.ts +++ b/src/api/workspaces/index.ts @@ -24,6 +24,7 @@ import type { WorkspaceSsoConfigInput } from '@/types/workspaces'; import type { APIResponse, APIResponseData } from '@/types/api'; +import { withDemoMock } from '@/utils/withDemoMock'; interface CreateWorkspaceInput { /** @@ -60,17 +61,20 @@ export async function leaveWorkspace(workspaceId: string): Promise { * Returns all user's workspaces and project. * @returns */ -export async function getAllWorkspacesWithProjects(): Promise> { - return api.call(QUERY_ALL_WORKSPACES_WITH_PROJECTS, undefined, undefined, { - initial: true, +export const getAllWorkspacesWithProjects = withDemoMock( + async function getAllWorkspacesWithProjects(): Promise> { + return api.call(QUERY_ALL_WORKSPACES_WITH_PROJECTS, undefined, undefined, { + initial: true, - /** - * This request calls on the app start, so we don't want to break app if something goes wrong - * With this flag, errors from the API won't be thrown, but returned in the response for further handling - */ - allowErrors: true, - }); -} + /** + * This request calls on the app start, so we don't want to break app if something goes wrong + * With this flag, errors from the API won't be thrown, but returned in the response for further handling + */ + allowErrors: true, + }); + }, + '/src/api/workspaces/mocks/getAllWorkspacesWithProjects.mock.ts' +); /** * Invites user to workspace by email @@ -113,10 +117,15 @@ export async function confirmInvite(workspaceId: string, inviteHash: string): Pr * @param ids – id of fetching workspaces * @returns */ -export async function getWorkspaces(ids: string[]): Promise { +async function getWorkspacesRequest(ids: string[]): Promise { return (await api.callOld(QUERY_WORKSPACES, { ids })).workspaces; } +export const getWorkspaces = withDemoMock( + getWorkspacesRequest, + '/src/api/workspaces/mocks/getWorkspaces.mock.ts' +); + /** * Get workspace balance * @param ids – id of fetching workspaces balance @@ -147,7 +156,7 @@ export async function updateWorkspace(id: string, name: string, description: str * @param state - if true, grant permissions, if false, withdraw them * @returns */ -export async function grantAdminPermissions(workspaceId: string, userId: string, state = true): Promise { +async function grantAdminPermissionsRequest(workspaceId: string, userId: string, state = true): Promise { return (await api.callOld(MUTATION_GRANT_ADMIN_PERMISSIONS, { workspaceId, userId, @@ -155,6 +164,11 @@ export async function grantAdminPermissions(workspaceId: string, userId: string, })).grantAdmin; } +export const grantAdminPermissions = withDemoMock( + grantAdminPermissionsRequest, + '/src/api/workspaces/mocks/grantAdminPermissions.mock.ts' +); + /** * Remove user from workspace * @param workspaceId - id of workspace where user is participate @@ -162,7 +176,7 @@ export async function grantAdminPermissions(workspaceId: string, userId: string, * @param userEmail - email of user to remove * @returns */ -export async function removeUserFromWorkspace( +async function removeUserFromWorkspaceRequest( workspaceId: string, userId: string, userEmail: string @@ -174,6 +188,11 @@ export async function removeUserFromWorkspace( })).removeMemberFromWorkspace; } +export const removeUserFromWorkspace = withDemoMock( + removeUserFromWorkspaceRequest, + '/src/api/workspaces/mocks/removeUserFromWorkspace.mock.ts' +); + /** * Changes workspace tariff plan * @param workspaceId - id of workspace to change plan diff --git a/src/api/workspaces/mocks/getAllWorkspacesWithProjects.mock.ts b/src/api/workspaces/mocks/getAllWorkspacesWithProjects.mock.ts new file mode 100644 index 000000000..62e124ce8 --- /dev/null +++ b/src/api/workspaces/mocks/getAllWorkspacesWithProjects.mock.ts @@ -0,0 +1,49 @@ +/** + * Mock: getAllWorkspacesWithProjects + * + * Returns demo workspace with project and events + */ + +import { DEMO_WORKSPACE, DEMO_PROJECTS, getDemoEventsByProjectId } from '@/api/mock-db'; +import type { DailyEventsPortion, Workspace } from '@hawk.so/types'; +import { MILLISECONDS_IN_SECOND, SECONDS_IN_DAY } from '@/utils/time'; + +/** + * Create fresh daily events portion + */ +function createDailyEventsPortion(projectId: string): DailyEventsPortion { + const now_seconds = Math.floor(Date.now() / MILLISECONDS_IN_SECOND); + const dayTimestamp = Math.floor(now_seconds / SECONDS_IN_DAY) * SECONDS_IN_DAY; + const projectEvents = getDemoEventsByProjectId(projectId); + + return { + nextCursor: null, + dailyEvents: projectEvents.map(event => ({ + id: `daily-${event.id}`, + groupingTimestamp: dayTimestamp, + count: event.totalCount, + affectedUsers: event.usersAffected, + event, + })), + }; +} + +export default function mockGetAllWorkspacesWithProjects(): { + data: { workspaces: Workspace[] }; + errors: unknown[]; +} { + return { + data: { + workspaces: [ + { + ...DEMO_WORKSPACE, + projects: DEMO_PROJECTS.map((project) => ({ + ...project, + dailyEventsPortion: createDailyEventsPortion(project.id), + })), + }, + ], + }, + errors: [], + }; +} diff --git a/src/api/workspaces/mocks/getWorkspaces.mock.ts b/src/api/workspaces/mocks/getWorkspaces.mock.ts new file mode 100644 index 000000000..d6ce89444 --- /dev/null +++ b/src/api/workspaces/mocks/getWorkspaces.mock.ts @@ -0,0 +1,20 @@ +import type { Workspace } from '@/types/workspaces'; +import { DEMO_WORKSPACE, DEMO_WORKSPACE_ID } from '@/api/mock-db'; + +/** + * Mock: getWorkspaces + * + * Returns demo workspace if requesting for demo workspace ID + */ +export default function mockGetWorkspaces(ids: string[]): Workspace[] { + if (!ids || ids.length === 0) { + return []; + } + + // Return demo workspace if demo workspace ID is requested + if (ids.includes(DEMO_WORKSPACE_ID)) { + return [DEMO_WORKSPACE]; + } + + return []; +} diff --git a/src/api/workspaces/mocks/grantAdminPermissions.mock.ts b/src/api/workspaces/mocks/grantAdminPermissions.mock.ts new file mode 100644 index 000000000..e674969cc --- /dev/null +++ b/src/api/workspaces/mocks/grantAdminPermissions.mock.ts @@ -0,0 +1,3 @@ +export default function mockGrantAdminPermissions(): boolean { + return true; +} diff --git a/src/api/workspaces/mocks/removeUserFromWorkspace.mock.ts b/src/api/workspaces/mocks/removeUserFromWorkspace.mock.ts new file mode 100644 index 000000000..d14e763ef --- /dev/null +++ b/src/api/workspaces/mocks/removeUserFromWorkspace.mock.ts @@ -0,0 +1,3 @@ +export default function mockRemoveUserFromWorkspace(): boolean { + return true; +} diff --git a/src/components/AppDemoBanner.vue b/src/components/AppDemoBanner.vue new file mode 100644 index 000000000..dada21ff3 --- /dev/null +++ b/src/components/AppDemoBanner.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index cbe9e004d..a89c89b37 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -1,42 +1,48 @@ @@ -28,7 +36,7 @@