diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index 932ca47..607863e 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -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') diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index a1b0775..cef2e20 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -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') diff --git a/ios/App/Podfile b/ios/App/Podfile index b4e2a32..b538501 100644 --- a/ios/App/Podfile +++ b/ios/App/Podfile @@ -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' diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index 3c20ae8..84d18d4 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -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 diff --git a/package-lock.json b/package-lock.json index fe2e7ac..a1f4302 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -1468,6 +1469,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", diff --git a/package.json b/package.json index fbd3759..0b7b348 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,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", diff --git a/public/locales/en/common.json b/public/locales/en/common.json index fa493cf..8df2fcf 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -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" + } } } diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index d03ef6d..085ef01 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -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." } } } diff --git a/src/service/FeedbackService.js b/src/service/FeedbackService.js new file mode 100644 index 0000000..70e1af7 --- /dev/null +++ b/src/service/FeedbackService.js @@ -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_', + '', + '---', + '', + '
Environment', + '', + `- 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, '```'] + : []), + '', + '
', + ].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', +} diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx index 65375e7..1f3fc12 100644 --- a/src/utils/Fetcher.jsx +++ b/src/utils/Fetcher.jsx @@ -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 }) } diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index 98955d5..adbd68e 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -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' @@ -1456,6 +1457,7 @@ const MyChores = () => { /> + {addTaskModalOpen && ( { + 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 ( + + + {step === STEP.SENTIMENT && ( + <> + + {t('feedback.sentiment.title')} + + + {t('feedback.sentiment.subtitle')} + + + {SENTIMENT_OPTIONS.map(option => ( + + ))} + + + + )} + + {step === STEP.DETAILS && ( + <> + + {t('feedback.details.title')} + + + {FEEDBACK_CATEGORIES.map(item => ( + setCategory(category === item ? null : item)} + > + {t(`feedback.categories.${item}`)} + + ))} + + + + + {t('feedback.details.messageLabel')} + +