Compare commits
3 Commits
v1.2.37
...
backup-in-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c832e6af6c | ||
|
|
fc7f46618f | ||
|
|
b2d4d90c9c |
@@ -9,6 +9,7 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-community-in-app-review')
|
||||
implementation project(':capacitor-community-speech-recognition')
|
||||
implementation project(':capacitor-community-sqlite')
|
||||
implementation project(':capacitor-app')
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
include ':capacitor-android'
|
||||
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'
|
||||
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
|
||||
pod 'Capacitor', :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 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||
|
||||
@@ -18,6 +18,8 @@ PODS:
|
||||
- Capacitor
|
||||
- CapacitorBrowser (8.0.3):
|
||||
- Capacitor
|
||||
- CapacitorCommunityInAppReview (8.0.0):
|
||||
- Capacitor
|
||||
- CapacitorCommunitySpeechRecognition (7.0.1):
|
||||
- Capacitor
|
||||
- CapacitorCommunitySqlite (8.1.0):
|
||||
@@ -143,6 +145,7 @@ DEPENDENCIES:
|
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
||||
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
|
||||
- "CapacitorCommunityInAppReview (from `../../node_modules/@capacitor-community/in-app-review`)"
|
||||
- "CapacitorCommunitySpeechRecognition (from `../../node_modules/@capacitor-community/speech-recognition`)"
|
||||
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
|
||||
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
|
||||
@@ -195,6 +198,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/@capacitor/app"
|
||||
CapacitorBrowser:
|
||||
:path: "../../node_modules/@capacitor/browser"
|
||||
CapacitorCommunityInAppReview:
|
||||
:path: "../../node_modules/@capacitor-community/in-app-review"
|
||||
CapacitorCommunitySpeechRecognition:
|
||||
:path: "../../node_modules/@capacitor-community/speech-recognition"
|
||||
CapacitorCommunitySqlite:
|
||||
@@ -239,6 +244,7 @@ SPEC CHECKSUMS:
|
||||
Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b
|
||||
CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650
|
||||
CapacitorBrowser: c987c73d09d8bd3b5ec13f06338b1e14d5d2be69
|
||||
CapacitorCommunityInAppReview: 4492bdd34aad4d27ed87949376022cc93b294ea1
|
||||
CapacitorCommunitySpeechRecognition: 3e03566c44c2bb3b52391a33d4518b1adbdeb38f
|
||||
CapacitorCommunitySqlite: eac6acfb852f46e7988fc59604d7f900498d354e
|
||||
CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09
|
||||
@@ -277,6 +283,6 @@ SPEC CHECKSUMS:
|
||||
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: 1099083fe561f8852fcef1bfcff4051f45db9770
|
||||
PODFILE CHECKSUM: 028fdeb50d56158db0a97459bd577ed700f03c0c
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "1.2.33",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@capacitor-community/in-app-review": "^8.0.0",
|
||||
"@capacitor-community/speech-recognition": "^7.0.1",
|
||||
"@capacitor-community/sqlite": "^8.0.0",
|
||||
"@capacitor/android": "^8.0.0",
|
||||
@@ -1495,6 +1496,15 @@
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor-community/speech-recognition/-/speech-recognition-7.0.1.tgz",
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/in-app-review": "^8.0.0",
|
||||
"@capacitor-community/speech-recognition": "^7.0.1",
|
||||
"@capacitor-community/sqlite": "^8.0.0",
|
||||
"@capacitor/android": "^8.0.0",
|
||||
|
||||
@@ -29,5 +29,51 @@
|
||||
"activities": "Activities",
|
||||
"points": "Points",
|
||||
"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": {
|
||||
"title": "Developer Settings",
|
||||
"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',
|
||||
headers: HEADERS(),
|
||||
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 CalendarDual from '../components/CalendarDual'
|
||||
import CalendarMonthly from '../components/CalendarMonthly.jsx'
|
||||
import FeedbackPrompt from '../components/FeedbackPrompt.jsx'
|
||||
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
|
||||
import { useProjects } from '../Projects/ProjectQueries.js'
|
||||
import ChoreListView from './ChoreListView.jsx'
|
||||
@@ -1479,6 +1480,7 @@ const MyChores = () => {
|
||||
/>
|
||||
</Box>
|
||||
<NotificationAccessSnackbar />
|
||||
<FeedbackPrompt />
|
||||
{addTaskModalOpen && (
|
||||
<TaskInput
|
||||
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 { 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 { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
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 { apiClient } from '../../utils/ApiClient'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
@@ -13,11 +22,13 @@ import { RefreshToken } from '../../utils/Fetcher'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import { syncEngine } from '../../utils/SyncEngine'
|
||||
import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage'
|
||||
import FeedbackModal from '../Modals/FeedbackModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
|
||||
const DeveloperSettings = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
@@ -41,6 +52,8 @@ const DeveloperSettings = () => {
|
||||
const [scheduledNotifications, setScheduledNotifications] = useState([])
|
||||
const [isLoadingNotifications, setIsLoadingNotifications] = useState(false)
|
||||
const [isResettingSync, setIsResettingSync] = useState(false)
|
||||
const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
|
||||
const [feedbackEligibility, setFeedbackEligibility] = useState(null)
|
||||
const [syncDiagnostics, setSyncDiagnostics] = useState({
|
||||
cursor: 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 => {
|
||||
if (!scheduleTime) return 'neutral'
|
||||
|
||||
@@ -642,6 +694,166 @@ const DeveloperSettings = () => {
|
||||
</Box>
|
||||
</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 && (
|
||||
<Card variant='outlined'>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
@@ -990,6 +1202,14 @@ const DeveloperSettings = () => {
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<FeedbackModal
|
||||
open={feedbackModalOpen}
|
||||
onClose={() => {
|
||||
setFeedbackModalOpen(false)
|
||||
refreshFeedbackEligibility()
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Circle,
|
||||
Code,
|
||||
FamilyRestroom,
|
||||
Feedback,
|
||||
Language,
|
||||
Notifications,
|
||||
Palette,
|
||||
@@ -31,16 +32,19 @@ import {
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { isParentUser } from '../../utils/UserHelpers'
|
||||
import FeedbackModal from '../Modals/FeedbackModal'
|
||||
|
||||
const SettingsOverview = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const navigate = useNavigate()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false)
|
||||
|
||||
const settingsCards = [
|
||||
{
|
||||
@@ -122,10 +126,21 @@ const SettingsOverview = () => {
|
||||
description: t('overview.sections.developer.description'),
|
||||
icon: <Code />,
|
||||
},
|
||||
{
|
||||
id: 'feedback',
|
||||
title: t('overview.sections.feedback.title'),
|
||||
description: t('overview.sections.feedback.description'),
|
||||
icon: <Feedback />,
|
||||
onSelect: () => setFeedbackOpen(true),
|
||||
},
|
||||
]
|
||||
|
||||
const handleCardClick = settingId => {
|
||||
navigate(`/settings/${settingId}`)
|
||||
const handleCardClick = setting => {
|
||||
if (setting.onSelect) {
|
||||
setting.onSelect()
|
||||
return
|
||||
}
|
||||
navigate(`/settings/${setting.id}`)
|
||||
}
|
||||
|
||||
// Filter settings based on user type
|
||||
@@ -303,7 +318,7 @@ const SettingsOverview = () => {
|
||||
{getAvailableSettings().map((setting, index) => (
|
||||
<ListItem key={setting.id} sx={{ p: 0 }}>
|
||||
<ListItemButton
|
||||
onClick={() => handleCardClick(setting.id)}
|
||||
onClick={() => handleCardClick(setting)}
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: 'background.level1',
|
||||
@@ -367,6 +382,11 @@ const SettingsOverview = () => {
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<FeedbackModal
|
||||
open={feedbackOpen}
|
||||
onClose={() => setFeedbackOpen(false)}
|
||||
/>
|
||||
</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 { defineConfig } from 'vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import pkg from './package.json'
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
define: {
|
||||
'import.meta.env.VITE_APP_VERSION': JSON.stringify(pkg.version),
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
|
||||
Reference in New Issue
Block a user