Add support for in app feedback
This commit is contained in:
@@ -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')
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
10
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
475
src/service/FeedbackService.js
Normal file
475
src/service/FeedbackService.js
Normal 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',
|
||||||
|
}
|
||||||
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
324
src/views/Modals/FeedbackModal.jsx
Normal file
324
src/views/Modals/FeedbackModal.jsx
Normal 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
|
||||||
@@ -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'>
|
||||||
|
"Force next prompt" 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/views/components/FeedbackPrompt.jsx
Normal file
58
src/views/components/FeedbackPrompt.jsx
Normal 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
|
||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user