Add support for in app feedback

This commit is contained in:
Mo Tarbin
2026-07-29 02:30:34 -04:00
parent b2949b2dd9
commit fc7f46618f
16 changed files with 1189 additions and 5 deletions

View File

@@ -9,6 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies { dependencies {
implementation project(':capacitor-community-in-app-review')
implementation project(':capacitor-community-speech-recognition') implementation project(':capacitor-community-speech-recognition')
implementation project(':capacitor-community-sqlite') implementation project(':capacitor-community-sqlite')
implementation project(':capacitor-app') implementation project(':capacitor-app')

View File

@@ -2,6 +2,9 @@
include ':capacitor-android' include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-in-app-review'
project(':capacitor-community-in-app-review').projectDir = new File('../node_modules/@capacitor-community/in-app-review/android')
include ':capacitor-community-speech-recognition' include ':capacitor-community-speech-recognition'
project(':capacitor-community-speech-recognition').projectDir = new File('../node_modules/@capacitor-community/speech-recognition/android') project(':capacitor-community-speech-recognition').projectDir = new File('../node_modules/@capacitor-community/speech-recognition/android')

View File

@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCommunityInAppReview', :path => '../../node_modules/@capacitor-community/in-app-review'
pod 'CapacitorCommunitySpeechRecognition', :path => '../../node_modules/@capacitor-community/speech-recognition' pod 'CapacitorCommunitySpeechRecognition', :path => '../../node_modules/@capacitor-community/speech-recognition'
pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite' pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app' pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'

View File

@@ -18,6 +18,8 @@ PODS:
- Capacitor - Capacitor
- CapacitorBrowser (8.0.3): - CapacitorBrowser (8.0.3):
- Capacitor - Capacitor
- CapacitorCommunityInAppReview (8.0.0):
- Capacitor
- CapacitorCommunitySpeechRecognition (7.0.1): - CapacitorCommunitySpeechRecognition (7.0.1):
- Capacitor - Capacitor
- CapacitorCommunitySqlite (8.1.0): - CapacitorCommunitySqlite (8.1.0):
@@ -143,6 +145,7 @@ DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)" - "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorApp (from `../../node_modules/@capacitor/app`)" - "CapacitorApp (from `../../node_modules/@capacitor/app`)"
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)" - "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
- "CapacitorCommunityInAppReview (from `../../node_modules/@capacitor-community/in-app-review`)"
- "CapacitorCommunitySpeechRecognition (from `../../node_modules/@capacitor-community/speech-recognition`)" - "CapacitorCommunitySpeechRecognition (from `../../node_modules/@capacitor-community/speech-recognition`)"
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)" - "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
@@ -195,6 +198,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/app" :path: "../../node_modules/@capacitor/app"
CapacitorBrowser: CapacitorBrowser:
:path: "../../node_modules/@capacitor/browser" :path: "../../node_modules/@capacitor/browser"
CapacitorCommunityInAppReview:
:path: "../../node_modules/@capacitor-community/in-app-review"
CapacitorCommunitySpeechRecognition: CapacitorCommunitySpeechRecognition:
:path: "../../node_modules/@capacitor-community/speech-recognition" :path: "../../node_modules/@capacitor-community/speech-recognition"
CapacitorCommunitySqlite: CapacitorCommunitySqlite:
@@ -239,6 +244,7 @@ SPEC CHECKSUMS:
Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b
CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650 CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650
CapacitorBrowser: c987c73d09d8bd3b5ec13f06338b1e14d5d2be69 CapacitorBrowser: c987c73d09d8bd3b5ec13f06338b1e14d5d2be69
CapacitorCommunityInAppReview: 4492bdd34aad4d27ed87949376022cc93b294ea1
CapacitorCommunitySpeechRecognition: 3e03566c44c2bb3b52391a33d4518b1adbdeb38f CapacitorCommunitySpeechRecognition: 3e03566c44c2bb3b52391a33d4518b1adbdeb38f
CapacitorCommunitySqlite: eac6acfb852f46e7988fc59604d7f900498d354e CapacitorCommunitySqlite: eac6acfb852f46e7988fc59604d7f900498d354e
CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09 CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09
@@ -277,6 +283,6 @@ SPEC CHECKSUMS:
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351 ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: 1099083fe561f8852fcef1bfcff4051f45db9770 PODFILE CHECKSUM: 028fdeb50d56158db0a97459bd577ed700f03c0c
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

10
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.2.33", "version": "1.2.33",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@capacitor-community/in-app-review": "^8.0.0",
"@capacitor-community/speech-recognition": "^7.0.1", "@capacitor-community/speech-recognition": "^7.0.1",
"@capacitor-community/sqlite": "^8.0.0", "@capacitor-community/sqlite": "^8.0.0",
"@capacitor/android": "^8.0.0", "@capacitor/android": "^8.0.0",
@@ -1495,6 +1496,15 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@capacitor-community/in-app-review": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@capacitor-community/in-app-review/-/in-app-review-8.0.0.tgz",
"integrity": "sha512-AYpEkIjdv27WGS3hnlc7xAQ1BhaBvD2svgQBfHF4P2Olw1N6a7MSCOQC6O4SuxmdBdJ2fM6uFeo1jyNjKLtIPw==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor-community/speech-recognition": { "node_modules/@capacitor-community/speech-recognition": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/@capacitor-community/speech-recognition/-/speech-recognition-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@capacitor-community/speech-recognition/-/speech-recognition-7.0.1.tgz",

View File

@@ -41,6 +41,7 @@
"postinstall": "patch-package" "postinstall": "patch-package"
}, },
"dependencies": { "dependencies": {
"@capacitor-community/in-app-review": "^8.0.0",
"@capacitor-community/speech-recognition": "^7.0.1", "@capacitor-community/speech-recognition": "^7.0.1",
"@capacitor-community/sqlite": "^8.0.0", "@capacitor-community/sqlite": "^8.0.0",
"@capacitor/android": "^8.0.0", "@capacitor/android": "^8.0.0",

View File

@@ -29,5 +29,51 @@
"activities": "Activities", "activities": "Activities",
"points": "Points", "points": "Points",
"settings": "Settings" "settings": "Settings"
},
"feedback": {
"later": "Maybe later",
"sentiment": {
"title": "How's Donetick working for you?",
"subtitle": "Your answer helps us decide what to build next.",
"options": {
"love": "Love it",
"okay": "It's okay",
"issues": "Having issues"
}
},
"categories": {
"bugs": "Bugs",
"missingFeature": "Missing feature",
"tooComplicated": "Too complicated",
"slow": "Slow",
"notifications": "Notifications",
"ai": "AI",
"other": "Other"
},
"details": {
"title": "What could we improve?",
"messageLabel": "Tell us more",
"messagePlaceholder": "What happened, or what would make this better?",
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
"submit": "Send feedback",
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
"submitSelfHosted": "Continue to GitHub"
},
"review": {
"title": "Glad you're enjoying it!",
"subtitle": "A rating or a star helps other people find Donetick.",
"github": "Star on GitHub",
"appStore": "Rate on the App Store",
"playStore": "Rate on Google Play"
},
"thanks": {
"title": "Thanks for the feedback",
"subtitle": "We read every response and it shapes what we work on next."
},
"github": {
"title": "Report it on GitHub",
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
"open": "Open the issue"
}
} }
} }

View File

@@ -168,6 +168,10 @@
"developer": { "developer": {
"title": "Developer Settings", "title": "Developer Settings",
"description": "View technical information about authentication tokens, SSE connections, and debug data." "description": "View technical information about authentication tokens, SSE connections, and debug data."
},
"feedback": {
"title": "Send Feedback",
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
} }
} }
} }

View File

@@ -0,0 +1,475 @@
import { InAppReview } from '@capacitor-community/in-app-review'
import { Capacitor } from '@capacitor/core'
import { Device } from '@capacitor/device'
import { Preferences } from '@capacitor/preferences'
import { isOfficialDonetickInstance } from '../utils/FeatureToggle'
const STATE_KEY = 'feedbackState'
// Eligibility thresholds for the automatic sentiment prompt.
const MIN_COMPLETIONS = 10
const MIN_DAYS_SINCE_SIGNUP = 7
const COOLDOWN_DAYS = 120
// A recent crash/API failure poisons the sentiment reading, so hold off.
const ERROR_QUIET_PERIOD_MS = 10 * 60 * 1000
const APP_STORE_URL =
'https://apps.apple.com/app/apple-store/id6742807441?pt=127258663&ct=website&mt=8'
const PLAY_STORE_URL =
'https://play.google.com/store/apps/details?id=com.donetick.app'
const GITHUB_URL = 'https://github.com/donetick/donetick'
const DAY_MS = 24 * 60 * 60 * 1000
const defaultState = {
completions: 0,
// null until the first prompt is shown/snoozed.
lastPromptedAt: null,
lastPromptedVersion: null,
dismissCount: 0,
reviewRequestedAt: null,
lastSentiment: null,
optedOut: false,
// Developer Settings escape hatch; never set in normal use.
devForced: false,
}
let cachedState = null
const readState = async () => {
if (cachedState) return cachedState
try {
const { value } = await Preferences.get({ key: STATE_KEY })
cachedState = { ...defaultState, ...(value ? JSON.parse(value) : {}) }
} catch (error) {
console.warn('FeedbackService: unable to read state', error)
cachedState = { ...defaultState }
}
return cachedState
}
const writeState = async patch => {
const current = await readState()
cachedState = { ...current, ...patch }
try {
await Preferences.set({
key: STATE_KEY,
value: JSON.stringify(cachedState),
})
} catch (error) {
console.warn('FeedbackService: unable to persist state', error)
}
return cachedState
}
export const getFeedbackState = () => readState()
/**
* Counts a completed task towards prompt eligibility. Called from the single
* network choke point for completions so offline completions are counted when
* they sync rather than twice.
*/
export const recordTaskCompleted = async () => {
const state = await readState()
// Stop writing once we are well past the threshold; nothing reads the exact
// number beyond reporting it as context.
if (state.completions > MIN_COMPLETIONS * 100) return
await writeState({ completions: state.completions + 1 })
}
// ---------------------------------------------------------------------------
// Recent error breadcrumbs (in memory only, never persisted)
// ---------------------------------------------------------------------------
const recentErrors = []
const MAX_ERRORS = 5
export const recordFeedbackError = message => {
if (!message) return
recentErrors.push({ at: Date.now(), message: String(message).slice(0, 300) })
if (recentErrors.length > MAX_ERRORS) recentErrors.shift()
}
let errorListenersInstalled = false
export const installFeedbackErrorListeners = () => {
if (errorListenersInstalled || typeof window === 'undefined') return
errorListenersInstalled = true
window.addEventListener('error', event => {
recordFeedbackError(event?.message)
})
window.addEventListener('unhandledrejection', event => {
recordFeedbackError(event?.reason?.message || event?.reason)
})
}
const hasRecentError = () =>
recentErrors.some(error => Date.now() - error.at < ERROR_QUIET_PERIOD_MS)
// ---------------------------------------------------------------------------
// Context collection
// ---------------------------------------------------------------------------
const getAppVersion = async () => {
if (Capacitor.isNativePlatform()) {
try {
const { App } = await import('@capacitor/app')
const info = await App.getInfo()
return `${info.version} (${info.build})`
} catch {
// fall through to the web bundle version
}
}
return import.meta.env.VITE_APP_VERSION || 'web'
}
const getDeviceContext = async () => {
try {
const info = await Device.getInfo()
return {
deviceModel: [info.manufacturer, info.model].filter(Boolean).join(' '),
osVersion: `${info.operatingSystem} ${info.osVersion}`,
}
} catch {
return { deviceModel: 'unknown', osVersion: 'unknown' }
}
}
/**
* Everything we attach to a submission without asking the user for it.
*/
export const collectFeedbackContext = async ({ feature, userProfile } = {}) => {
const [version, device, state, isCloud] = await Promise.all([
getAppVersion(),
getDeviceContext(),
readState(),
isOfficialDonetickInstance().catch(() => false),
])
const signupDate = userProfile?.created_at
const daysSinceSignup = signupDate
? Math.floor((Date.now() - new Date(signupDate).getTime()) / DAY_MS)
: null
return {
appVersion: version,
platform: Capacitor.getPlatform(),
isNative: Capacitor.isNativePlatform(),
deviceModel: device.deviceModel,
osVersion: device.osVersion,
locale:
localStorage.getItem('i18nextLng') || navigator.language || 'unknown',
hosting: isCloud ? 'cloud' : 'self-hosted',
feature: feature || 'unknown',
tasksCompleted: state.completions,
daysSinceSignup,
userId: userProfile?.id ?? null,
subscription: userProfile?.subscription ?? null,
recentErrors: recentErrors.map(error => error.message),
submittedAt: new Date().toISOString(),
}
}
// ---------------------------------------------------------------------------
// Eligibility
// ---------------------------------------------------------------------------
/**
* Runs every gate and reports which ones failed, so Developer Settings can
* explain why the prompt is or isn't showing rather than just saying "no".
*/
export const evaluatePromptEligibility = async ({ userProfile } = {}) => {
const state = await readState()
const version = await getAppVersion()
if (state.devForced) {
return { eligible: true, forced: true, blockers: [], state, version }
}
const blockers = []
if (state.optedOut) {
blockers.push('User opted out (chose a sentiment, or dismissed 3 times)')
}
if (state.completions < MIN_COMPLETIONS) {
blockers.push(
`Only ${state.completions} completions, needs ${MIN_COMPLETIONS}`,
)
}
if (hasRecentError()) {
blockers.push('An error occurred in the last 10 minutes')
}
const signupDate = userProfile?.createdAt
if (signupDate) {
const days = (Date.now() - new Date(signupDate).getTime()) / DAY_MS
if (days < MIN_DAYS_SINCE_SIGNUP) {
blockers.push(
`Account is ${Math.floor(days)} days old, needs ${MIN_DAYS_SINCE_SIGNUP}`,
)
}
}
if (state.lastPromptedAt) {
const daysSincePrompt = (Date.now() - state.lastPromptedAt) / DAY_MS
if (daysSincePrompt < COOLDOWN_DAYS) {
blockers.push(
`Cooldown: ${Math.ceil(COOLDOWN_DAYS - daysSincePrompt)} days remaining`,
)
}
}
// Never ask twice on the same build, even after the cooldown expires.
if (state.lastPromptedVersion && state.lastPromptedVersion === version) {
blockers.push(`Already prompted on this version (${version})`)
}
return {
eligible: blockers.length === 0,
forced: false,
blockers,
state,
version,
}
}
export const shouldShowSentimentPrompt = async options =>
(await evaluatePromptEligibility(options)).eligible
/** Developer Settings: bypass every gate on the next eligibility check. */
export const setDevForcedPrompt = forced => writeState({ devForced: !!forced })
/** Developer Settings: back to a never-prompted, zero-completions user. */
export const resetFeedbackState = async () => {
cachedState = { ...defaultState }
try {
await Preferences.remove({ key: STATE_KEY })
} catch (error) {
console.warn('FeedbackService: unable to clear state', error)
}
return cachedState
}
export const markPromptShown = async () => {
const version = await getAppVersion()
return writeState({
lastPromptedAt: Date.now(),
lastPromptedVersion: version,
// A forced prompt is spent once shown, otherwise it would fire on every
// visit to My Chores.
devForced: false,
})
}
export const markPromptDismissed = async () => {
const state = await readState()
const dismissCount = state.dismissCount + 1
// Three dismissals in a row is an answer: stop asking automatically.
return writeState({ dismissCount, optedOut: dismissCount >= 3 })
}
export const markSentiment = async sentiment =>
writeState({ lastSentiment: sentiment, dismissCount: 0 })
export const optOutOfFeedbackPrompts = () => writeState({ optedOut: true })
// ---------------------------------------------------------------------------
// Store review + submission
// ---------------------------------------------------------------------------
export const getStoreUrl = () => {
const platform = Capacitor.getPlatform()
if (platform === 'ios') return APP_STORE_URL
if (platform === 'android') return PLAY_STORE_URL
return GITHUB_URL
}
export const storeLinks = {
appStore: APP_STORE_URL,
playStore: PLAY_STORE_URL,
github: GITHUB_URL,
}
/**
* Asks the OS to show its native review dialog. The OS decides whether to
* actually display it and gives no feedback either way, so this resolves true
* only to mean "the request went through".
*/
export const requestStoreReview = async () => {
if (!Capacitor.isNativePlatform()) return false
try {
await InAppReview.requestReview()
await writeState({ reviewRequestedAt: Date.now(), optedOut: true })
return true
} catch (error) {
console.warn('FeedbackService: review request failed', error)
return false
}
}
const WEBHOOK_URL = import.meta.env.VITE_FEEDBACK_WEBHOOK_URL
/**
* A chat-provider webhook pasted straight into the env var. That can't work —
* the app posts its own schema, which Discord rejects with "Cannot send an
* empty message" (50006) — and it would publish the webhook to every user,
* since VITE_ vars are baked into the bundle. Relay through workers/feedback.
*/
const isRawChatWebhook = url =>
/^https:\/\/(discord(app)?\.com\/api\/webhooks|hooks\.slack\.com)/i.test(
url || '',
)
export const isFeedbackSubmissionConfigured = () =>
Boolean(WEBHOOK_URL) && !isRawChatWebhook(WEBHOOK_URL)
/** Developer Settings: flag the misconfiguration in the UI, not just the log. */
export const isRawChatWebhookConfigured = () => isRawChatWebhook(WEBHOOK_URL)
/**
* Whether this app is talking to the hosted donetick.com service. Self-hosted
* instances route feedback to GitHub instead of the webhook, so their data
* never leaves infrastructure they control.
*/
export const isCloudInstance = () =>
isOfficialDonetickInstance().catch(() => false)
export const SUBMIT_RESULT = {
SENT: 'sent',
FAILED: 'failed',
UNCONFIGURED: 'unconfigured',
MISCONFIGURED: 'misconfigured',
SELF_HOSTED: 'self-hosted',
}
/**
* Builds a pre-filled GitHub issue for self-hosted users. Everything the
* webhook would have collected goes into the issue body, where the user can
* see and edit it before anything is published.
*/
export const buildGithubIssueUrl = ({
sentiment,
category,
message,
context,
}) => {
const labelFor = {
bugs: 'bug',
missingFeature: 'feature request',
tooComplicated: 'usability',
slow: 'performance',
notifications: 'notifications',
ai: 'ai',
other: 'feedback',
}
const title = `[${labelFor[category] || 'feedback'}] ${
message?.split('\n')[0]?.slice(0, 80) || 'App feedback'
}`
const body = [
message?.trim() || '_no description_',
'',
'---',
'',
'<details><summary>Environment</summary>',
'',
`- App version: ${context.appVersion}`,
`- Platform: ${context.platform}${context.isNative ? ' (native)' : ''}`,
`- Device: ${context.deviceModel}`,
`- OS: ${context.osVersion}`,
`- Locale: ${context.locale}`,
`- Hosting: ${context.hosting}`,
`- Screen: ${context.feature}`,
`- Sentiment: ${sentiment}`,
...(context.recentErrors.length
? ['', 'Recent errors:', '```', ...context.recentErrors, '```']
: []),
'',
'</details>',
].join('\n')
return `${GITHUB_URL}/issues/new?title=${encodeURIComponent(
title,
)}&body=${encodeURIComponent(body)}`
}
/**
* Posts the structured feedback plus the auto-collected context to the
* configured webhook. Never throws, so the UI can show a soft failure without
* losing what the user typed.
*
* Self-hosted instances are never relayed: the caller gets SELF_HOSTED plus a
* pre-filled GitHub issue URL to send the user to instead.
*/
export const submitFeedback = async ({
sentiment,
category,
message,
feature,
userProfile,
}) => {
const context = await collectFeedbackContext({ feature, userProfile })
const payload = {
source: 'donetick-app',
sentiment,
category: category || null,
message: message?.trim() || null,
context,
}
// Enforced here rather than only in the UI so no future caller can leak a
// self-hosted user's feedback to the hosted relay.
if (context.hosting !== 'cloud') {
return {
result: SUBMIT_RESULT.SELF_HOSTED,
githubUrl: buildGithubIssueUrl({ sentiment, category, message, context }),
}
}
if (!WEBHOOK_URL) {
console.info('FeedbackService: no webhook configured, feedback:', payload)
return { result: SUBMIT_RESULT.UNCONFIGURED }
}
if (isRawChatWebhook(WEBHOOK_URL)) {
console.error(
'FeedbackService: VITE_FEEDBACK_WEBHOOK_URL points directly at a ' +
'Discord/Slack webhook. Discord will reject this with 50006 ' +
'("Cannot send an empty message") because the app posts its own ' +
'schema, and the URL would ship inside the public bundle. Deploy ' +
'workers/feedback and point the variable at the Worker instead.',
payload,
)
return { result: SUBMIT_RESULT.MISCONFIGURED }
}
try {
const response = await fetch(WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return {
result: response.ok ? SUBMIT_RESULT.SENT : SUBMIT_RESULT.FAILED,
}
} catch (error) {
console.warn('FeedbackService: submission failed', error)
return { result: SUBMIT_RESULT.FAILED }
}
}
export const FEEDBACK_CATEGORIES = [
'bugs',
'missingFeature',
'tooComplicated',
'slow',
'notifications',
'ai',
'other',
]
export const SENTIMENTS = {
LOVE: 'love',
OKAY: 'okay',
ISSUES: 'issues',
}

View File

@@ -166,6 +166,15 @@ const MarkChoreComplete = (id, body, completedDate, performer) => {
method: 'POST', method: 'POST',
headers: HEADERS(), headers: HEADERS(),
body: JSON.stringify(body), body: JSON.stringify(body),
}).then(response => {
if (response?.ok) {
// Single choke point for completions, so queued offline completions are
// counted once, when they sync.
import('../service/FeedbackService')
.then(({ recordTaskCompleted }) => recordTaskCompleted())
.catch(() => {})
}
return response
}) })
} }

View File

@@ -47,6 +47,7 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
import TaskInput from '../components/AddTaskModal' import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual' import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx' import CalendarMonthly from '../components/CalendarMonthly.jsx'
import FeedbackPrompt from '../components/FeedbackPrompt.jsx'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder' import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import { useProjects } from '../Projects/ProjectQueries.js' import { useProjects } from '../Projects/ProjectQueries.js'
import ChoreListView from './ChoreListView.jsx' import ChoreListView from './ChoreListView.jsx'
@@ -1479,6 +1480,7 @@ const MyChores = () => {
/> />
</Box> </Box>
<NotificationAccessSnackbar /> <NotificationAccessSnackbar />
<FeedbackPrompt />
{addTaskModalOpen && ( {addTaskModalOpen && (
<TaskInput <TaskInput
autoFocus={taskInputFocus} autoFocus={taskInputFocus}

View File

@@ -0,0 +1,324 @@
import { Browser } from '@capacitor/browser'
import { Android, Apple, Favorite, GitHub } from '@mui/icons-material'
import {
Box,
Button,
Chip,
CircularProgress,
Divider,
FormControl,
FormLabel,
Stack,
Textarea,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router-dom'
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
import { useUserProfile } from '../../queries/UserQueries'
import {
FEEDBACK_CATEGORIES,
isCloudInstance,
markSentiment,
requestStoreReview,
SENTIMENTS,
storeLinks,
submitFeedback,
SUBMIT_RESULT,
} from '../../service/FeedbackService'
import { Capacitor } from '@capacitor/core'
const STEP = {
SENTIMENT: 'sentiment',
DETAILS: 'details',
THANKS: 'thanks',
WEB_REVIEW: 'webReview',
GITHUB: 'github',
}
// Native webviews swallow target="_blank"; route through the system browser.
const openUrl = async url => {
if (Capacitor.isNativePlatform()) {
await Browser.open({ url })
} else {
window.open(url, '_blank', 'noopener,noreferrer')
}
}
const SENTIMENT_OPTIONS = [
{ value: SENTIMENTS.LOVE, emoji: '😍' },
{ value: SENTIMENTS.OKAY, emoji: '🙂' },
{ value: SENTIMENTS.ISSUES, emoji: '😕' },
]
/**
* Sentiment-first feedback flow. "Love it" routes to the native store review
* dialog (or star links on web); anything else collects structured feedback
* and never asks for a review.
*/
const FeedbackModal = ({ open, onClose, onDismiss }) => {
const { t } = useTranslation()
const { ResponsiveModal } = useResponsiveModal()
const { data: userProfile } = useUserProfile()
const location = useLocation()
const [step, setStep] = useState(STEP.SENTIMENT)
const [sentiment, setSentiment] = useState(null)
const [category, setCategory] = useState(null)
const [message, setMessage] = useState('')
const [submitting, setSubmitting] = useState(false)
const [isCloud, setIsCloud] = useState(true)
const [githubUrl, setGithubUrl] = useState(null)
useEffect(() => {
if (open) {
setStep(STEP.SENTIMENT)
setSentiment(null)
setCategory(null)
setMessage('')
setSubmitting(false)
setGithubUrl(null)
isCloudInstance().then(setIsCloud)
}
}, [open])
const handleClose = () => {
// Backing out before answering counts as a dismissal for the cooldown.
if (step === STEP.SENTIMENT) onDismiss?.()
onClose()
}
const handleSentiment = async value => {
setSentiment(value)
await markSentiment(value)
if (value !== SENTIMENTS.LOVE) {
setStep(STEP.DETAILS)
return
}
if (Capacitor.isNativePlatform()) {
const requested = await requestStoreReview()
if (requested) {
onClose()
return
}
}
setStep(STEP.WEB_REVIEW)
}
const handleSubmit = async () => {
setSubmitting(true)
const { result, githubUrl: url } = await submitFeedback({
sentiment,
category,
message,
feature: location.pathname,
userProfile,
})
setSubmitting(false)
// Self-hosted feedback is never relayed; hand the user a pre-filled issue
// instead so they choose what gets published.
if (result === SUBMIT_RESULT.SELF_HOSTED) {
setGithubUrl(url)
setStep(STEP.GITHUB)
return
}
setStep(STEP.THANKS)
}
const canSubmit = Boolean(category) || message.trim().length > 0
return (
<ResponsiveModal open={open} onClose={handleClose} size='md'>
<Stack spacing={2}>
{step === STEP.SENTIMENT && (
<>
<Typography level='h4' sx={{ fontWeight: 600 }}>
{t('feedback.sentiment.title')}
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{t('feedback.sentiment.subtitle')}
</Typography>
<Stack spacing={1} sx={{ mt: 1 }}>
{SENTIMENT_OPTIONS.map(option => (
<Button
key={option.value}
variant='outlined'
color='neutral'
size='lg'
onClick={() => handleSentiment(option.value)}
sx={{
justifyContent: 'flex-start',
fontWeight: 500,
py: 1.5,
}}
startDecorator={
<Box component='span' sx={{ fontSize: '1.4rem' }}>
{option.emoji}
</Box>
}
>
{t(`feedback.sentiment.options.${option.value}`)}
</Button>
))}
</Stack>
<Button variant='plain' color='neutral' onClick={handleClose}>
{t('feedback.later')}
</Button>
</>
)}
{step === STEP.DETAILS && (
<>
<Typography level='h4' sx={{ fontWeight: 600 }}>
{t('feedback.details.title')}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 0.5 }}>
{FEEDBACK_CATEGORIES.map(item => (
<Chip
key={item}
variant={category === item ? 'solid' : 'outlined'}
color={category === item ? 'primary' : 'neutral'}
size='lg'
onClick={() => setCategory(category === item ? null : item)}
>
{t(`feedback.categories.${item}`)}
</Chip>
))}
</Box>
<FormControl>
<FormLabel sx={{ fontWeight: 600 }}>
{t('feedback.details.messageLabel')}
</FormLabel>
<Textarea
minRows={3}
maxRows={6}
value={message}
onChange={e => setMessage(e.target.value)}
placeholder={t('feedback.details.messagePlaceholder')}
/>
</FormControl>
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{isCloud
? t('feedback.details.contextNote')
: t('feedback.details.contextNoteSelfHosted')}
</Typography>
<Divider />
<Stack direction='row' spacing={1} justifyContent='flex-end'>
<Button variant='plain' color='neutral' onClick={handleClose}>
{t('cancel')}
</Button>
<Button
variant='solid'
color='primary'
disabled={!canSubmit || submitting}
startDecorator={
submitting ? <CircularProgress size='sm' /> : null
}
onClick={handleSubmit}
>
{isCloud
? t('feedback.details.submit')
: t('feedback.details.submitSelfHosted')}
</Button>
</Stack>
</>
)}
{step === STEP.WEB_REVIEW && (
<>
<Typography
level='h4'
sx={{ fontWeight: 600 }}
startDecorator={<Favorite color='error' />}
>
{t('feedback.review.title')}
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{t('feedback.review.subtitle')}
</Typography>
<Stack spacing={1} sx={{ mt: 1 }}>
<Button
variant='outlined'
color='neutral'
startDecorator={<GitHub />}
onClick={() => openUrl(storeLinks.github)}
sx={{ justifyContent: 'flex-start' }}
>
{t('feedback.review.github')}
</Button>
<Button
variant='outlined'
color='neutral'
startDecorator={<Apple />}
onClick={() => openUrl(storeLinks.appStore)}
sx={{ justifyContent: 'flex-start' }}
>
{t('feedback.review.appStore')}
</Button>
<Button
variant='outlined'
color='neutral'
startDecorator={<Android />}
onClick={() => openUrl(storeLinks.playStore)}
sx={{ justifyContent: 'flex-start' }}
>
{t('feedback.review.playStore')}
</Button>
</Stack>
<Button variant='plain' color='neutral' onClick={onClose}>
{t('close')}
</Button>
</>
)}
{step === STEP.GITHUB && (
<>
<Typography level='h4' sx={{ fontWeight: 600 }}>
{t('feedback.github.title')}
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{t('feedback.github.subtitle')}
</Typography>
<Button
variant='solid'
color='primary'
startDecorator={<GitHub />}
onClick={() => {
openUrl(githubUrl)
onClose()
}}
>
{t('feedback.github.open')}
</Button>
<Button variant='plain' color='neutral' onClick={onClose}>
{t('close')}
</Button>
</>
)}
{step === STEP.THANKS && (
<>
<Typography level='h4' sx={{ fontWeight: 600 }}>
{t('feedback.thanks.title')}
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{t('feedback.thanks.subtitle')}
</Typography>
<Button variant='solid' color='primary' onClick={onClose}>
{t('close')}
</Button>
</>
)}
</Stack>
</ResponsiveModal>
)
}
export default FeedbackModal

View File

@@ -1,11 +1,20 @@
import { LocalNotifications } from '@capacitor/local-notifications' import { LocalNotifications } from '@capacitor/local-notifications'
import { Refresh, Token } from '@mui/icons-material' import { Refresh, Star, Token } from '@mui/icons-material'
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy' import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { networkManager } from '../../hooks/NetworkManager' import { networkManager } from '../../hooks/NetworkManager'
import useConfirmationModal from '../../hooks/useConfirmationModal' import useConfirmationModal from '../../hooks/useConfirmationModal'
import { useSSEContext } from '../../hooks/useSSEContext' import { useSSEContext } from '../../hooks/useSSEContext'
import { useUserProfile } from '../../queries/UserQueries'
import {
evaluatePromptEligibility,
isFeedbackSubmissionConfigured,
isRawChatWebhookConfigured,
requestStoreReview,
resetFeedbackState,
setDevForcedPrompt,
} from '../../service/FeedbackService'
import { useNotification } from '../../service/NotificationProvider' import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient' import { apiClient } from '../../utils/ApiClient'
import { commandQueue } from '../../utils/CommandQueue' import { commandQueue } from '../../utils/CommandQueue'
@@ -13,11 +22,13 @@ import { RefreshToken } from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB' import { offlineDB } from '../../utils/OfflineDB'
import { syncEngine } from '../../utils/SyncEngine' import { syncEngine } from '../../utils/SyncEngine'
import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage' import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage'
import FeedbackModal from '../Modals/FeedbackModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
const DeveloperSettings = () => { const DeveloperSettings = () => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { confirmModalConfig, showConfirmation } = useConfirmationModal() const { confirmModalConfig, showConfirmation } = useConfirmationModal()
const { data: userProfile } = useUserProfile()
const { const {
isConnected, isConnected,
isConnecting, isConnecting,
@@ -41,6 +52,8 @@ const DeveloperSettings = () => {
const [scheduledNotifications, setScheduledNotifications] = useState([]) const [scheduledNotifications, setScheduledNotifications] = useState([])
const [isLoadingNotifications, setIsLoadingNotifications] = useState(false) const [isLoadingNotifications, setIsLoadingNotifications] = useState(false)
const [isResettingSync, setIsResettingSync] = useState(false) const [isResettingSync, setIsResettingSync] = useState(false)
const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
const [feedbackEligibility, setFeedbackEligibility] = useState(null)
const [syncDiagnostics, setSyncDiagnostics] = useState({ const [syncDiagnostics, setSyncDiagnostics] = useState({
cursor: null, cursor: null,
lastSync: null, lastSync: null,
@@ -362,6 +375,45 @@ const DeveloperSettings = () => {
) )
} }
const refreshFeedbackEligibility = useCallback(async () => {
const result = await evaluatePromptEligibility({ userProfile })
setFeedbackEligibility(result)
return result
}, [userProfile])
useEffect(() => {
refreshFeedbackEligibility()
}, [refreshFeedbackEligibility])
const handleForceFeedbackPrompt = async () => {
await setDevForcedPrompt(true)
await refreshFeedbackEligibility()
showNotification({
type: 'success',
message: 'Next visit to My Chores will show the prompt after ~4s',
})
}
const handleResetFeedbackState = async () => {
await resetFeedbackState()
await refreshFeedbackEligibility()
showNotification({
type: 'success',
message: 'Feedback state cleared (completions, cooldown, opt-out)',
})
}
const handleRequestStoreReview = async () => {
const requested = await requestStoreReview()
await refreshFeedbackEligibility()
showNotification({
type: requested ? 'success' : 'warning',
message: requested
? 'Review requested. The OS decides whether to actually show it.'
: 'Not available — native platform only.',
})
}
const getNotificationStatusColor = scheduleTime => { const getNotificationStatusColor = scheduleTime => {
if (!scheduleTime) return 'neutral' if (!scheduleTime) return 'neutral'
@@ -642,6 +694,166 @@ const DeveloperSettings = () => {
</Box> </Box>
</Card> </Card>
<Card variant='outlined'>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
}}
>
<Typography level='title-lg'>Feedback & Review Prompt</Typography>
<Button
size='sm'
variant='soft'
startDecorator={<Refresh />}
onClick={refreshFeedbackEligibility}
>
Refresh
</Button>
</Box>
<Divider />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography level='title-sm'>Prompt Eligibility</Typography>
<Typography level='body-sm'>
Would auto-show:{' '}
<Chip
size='sm'
variant='soft'
color={feedbackEligibility?.eligible ? 'success' : 'neutral'}
>
{feedbackEligibility?.eligible ? 'Yes' : 'No'}
</Chip>
{feedbackEligibility?.forced && (
<Chip size='sm' variant='soft' color='warning' sx={{ ml: 1 }}>
Forced
</Chip>
)}
</Typography>
{feedbackEligibility?.blockers?.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}>
{feedbackEligibility.blockers.map(blocker => (
<Typography key={blocker} level='body-xs' color='neutral'>
{blocker}
</Typography>
))}
</Box>
)}
</Box>
<Divider />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography level='title-sm'>State</Typography>
<Typography level='body-sm'>
Completions counted:{' '}
<Chip size='sm' variant='soft'>
{feedbackEligibility?.state?.completions ?? 'N/A'}
</Chip>
</Typography>
<Typography level='body-sm'>
Last sentiment:{' '}
<Chip size='sm' variant='soft'>
{feedbackEligibility?.state?.lastSentiment ?? 'None'}
</Chip>
</Typography>
<Typography level='body-sm'>
Dismissals:{' '}
<Chip size='sm' variant='soft'>
{feedbackEligibility?.state?.dismissCount ?? 0}
</Chip>
</Typography>
<Typography level='body-sm'>
Opted out:{' '}
<Chip
size='sm'
variant='soft'
color={
feedbackEligibility?.state?.optedOut ? 'danger' : 'success'
}
>
{feedbackEligibility?.state?.optedOut ? 'Yes' : 'No'}
</Chip>
</Typography>
<Typography level='body-xs' color='neutral'>
Last prompted:{' '}
{formatDateTime(feedbackEligibility?.state?.lastPromptedAt)}
{feedbackEligibility?.state?.lastPromptedVersion
? ` on ${feedbackEligibility.state.lastPromptedVersion}`
: ''}
</Typography>
<Typography level='body-xs' color='neutral'>
Review requested:{' '}
{formatDateTime(feedbackEligibility?.state?.reviewRequestedAt)}
</Typography>
<Typography level='body-xs' color='neutral'>
Current version: {feedbackEligibility?.version ?? 'N/A'} · Webhook{' '}
{isFeedbackSubmissionConfigured()
? 'configured'
: 'NOT configured (submissions log to console)'}
</Typography>
{isRawChatWebhookConfigured() && (
<Typography level='body-xs' color='danger'>
VITE_FEEDBACK_WEBHOOK_URL points straight at a Discord/Slack
webhook. Discord rejects that with 50006, and the URL ships
inside the public bundle deploy workers/feedback and point the
variable at the Worker.
</Typography>
)}
</Box>
<Divider />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Typography level='title-sm'>Actions</Typography>
<Typography level='body-xs' color='neutral'>
&quot;Force next prompt&quot; bypasses every gate, then open My
Chores to see the automatic trigger.
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Button
size='sm'
variant='soft'
onClick={() => setFeedbackModalOpen(true)}
>
Open Flow
</Button>
<Button
size='sm'
variant='outlined'
color='neutral'
onClick={handleForceFeedbackPrompt}
>
Force Next Prompt
</Button>
<Button
size='sm'
variant='outlined'
color='neutral'
startDecorator={<Star />}
onClick={handleRequestStoreReview}
disabled={!isNativePlatform}
>
Request Store Review
</Button>
<Button
size='sm'
variant='soft'
color='danger'
onClick={handleResetFeedbackState}
>
Reset State
</Button>
</Box>
</Box>
</Box>
</Card>
{isNativePlatform && ( {isNativePlatform && (
<Card variant='outlined'> <Card variant='outlined'>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
@@ -990,6 +1202,14 @@ const DeveloperSettings = () => {
</Box> </Box>
</Card> </Card>
<FeedbackModal
open={feedbackModalOpen}
onClose={() => {
setFeedbackModalOpen(false)
refreshFeedbackEligibility()
}}
/>
<ConfirmationModal config={confirmModalConfig} /> <ConfirmationModal config={confirmModalConfig} />
</div> </div>
) )

View File

@@ -5,6 +5,7 @@ import {
Circle, Circle,
Code, Code,
FamilyRestroom, FamilyRestroom,
Feedback,
Language, Language,
Notifications, Notifications,
Palette, Palette,
@@ -31,16 +32,19 @@ import {
Stack, Stack,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries' import { useUserProfile } from '../../queries/UserQueries'
import { isPlusAccount } from '../../utils/Helpers' import { isPlusAccount } from '../../utils/Helpers'
import { isParentUser } from '../../utils/UserHelpers' import { isParentUser } from '../../utils/UserHelpers'
import FeedbackModal from '../Modals/FeedbackModal'
const SettingsOverview = () => { const SettingsOverview = () => {
const { t } = useTranslation('settings') const { t } = useTranslation('settings')
const navigate = useNavigate() const navigate = useNavigate()
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const [feedbackOpen, setFeedbackOpen] = useState(false)
const settingsCards = [ const settingsCards = [
{ {
@@ -122,10 +126,21 @@ const SettingsOverview = () => {
description: t('overview.sections.developer.description'), description: t('overview.sections.developer.description'),
icon: <Code />, icon: <Code />,
}, },
{
id: 'feedback',
title: t('overview.sections.feedback.title'),
description: t('overview.sections.feedback.description'),
icon: <Feedback />,
onSelect: () => setFeedbackOpen(true),
},
] ]
const handleCardClick = settingId => { const handleCardClick = setting => {
navigate(`/settings/${settingId}`) if (setting.onSelect) {
setting.onSelect()
return
}
navigate(`/settings/${setting.id}`)
} }
// Filter settings based on user type // Filter settings based on user type
@@ -303,7 +318,7 @@ const SettingsOverview = () => {
{getAvailableSettings().map((setting, index) => ( {getAvailableSettings().map((setting, index) => (
<ListItem key={setting.id} sx={{ p: 0 }}> <ListItem key={setting.id} sx={{ p: 0 }}>
<ListItemButton <ListItemButton
onClick={() => handleCardClick(setting.id)} onClick={() => handleCardClick(setting)}
sx={{ sx={{
'&:hover': { '&:hover': {
backgroundColor: 'background.level1', backgroundColor: 'background.level1',
@@ -367,6 +382,11 @@ const SettingsOverview = () => {
))} ))}
</List> </List>
</Box> </Box>
<FeedbackModal
open={feedbackOpen}
onClose={() => setFeedbackOpen(false)}
/>
</Container> </Container>
) )
} }

View File

@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react'
import { useUserProfile } from '../../queries/UserQueries'
import {
installFeedbackErrorListeners,
markPromptDismissed,
markPromptShown,
shouldShowSentimentPrompt,
} from '../../service/FeedbackService'
import FeedbackModal from '../Modals/FeedbackModal'
// Let the screen settle before interrupting.
const OPEN_DELAY_MS = 4000
/**
* Decides whether to surface the sentiment prompt automatically. Mount once,
* near the main task list.
*/
const FeedbackPrompt = () => {
const [open, setOpen] = useState(false)
const { data: userProfile } = useUserProfile()
useEffect(() => {
installFeedbackErrorListeners()
}, [])
useEffect(() => {
if (!userProfile) return
let timer = null
let cancelled = false
shouldShowSentimentPrompt({ userProfile }).then(eligible => {
if (!eligible || cancelled) return
timer = setTimeout(() => {
if (cancelled) return
markPromptShown()
setOpen(true)
}, OPEN_DELAY_MS)
})
return () => {
cancelled = true
if (timer) clearTimeout(timer)
}
}, [userProfile])
if (!open) return null
return (
<FeedbackModal
open={open}
onClose={() => setOpen(false)}
onDismiss={markPromptDismissed}
/>
)
}
export default FeedbackPrompt

View File

@@ -1,8 +1,12 @@
import react from '@vitejs/plugin-react-swc' import react from '@vitejs/plugin-react-swc'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import pkg from './package.json'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
define: {
'import.meta.env.VITE_APP_VERSION': JSON.stringify(pkg.version),
},
plugins: [ plugins: [
react(), react(),
VitePWA({ VitePWA({