mirror of
https://github.com/RealKai42/qwerty-learner.git
synced 2024-11-25 16:22:53 +08:00
feat: add donate card (#677)
This commit is contained in:
parent
f726091d05
commit
d520444f25
71
src/components/DonateCard/hooks/useWordStats.ts
Normal file
71
src/components/DonateCard/hooks/useWordStats.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { db } from '@/utils/db'
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function useChapterNumber() {
|
||||
const [chapterNumber, setChapterNumber] = useState<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChapterNumber = async () => {
|
||||
const number = await db.chapterRecords.count()
|
||||
setChapterNumber(number)
|
||||
}
|
||||
|
||||
fetchChapterNumber()
|
||||
}, [])
|
||||
|
||||
return chapterNumber
|
||||
}
|
||||
|
||||
export function useDayFromFirstWordRecord() {
|
||||
const [dayFromFirstWordRecord, setDayFromFirstWordRecord] = useState<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDayFromFirstWordRecord = async () => {
|
||||
const firstWordRecord = await db.wordRecords.orderBy('timeStamp').first()
|
||||
const firstWordRecordTimeStamp = firstWordRecord?.timeStamp || 0
|
||||
const now = dayjs()
|
||||
const timestamp = dayjs.unix(firstWordRecordTimeStamp)
|
||||
const daysPassed = now.diff(timestamp, 'day')
|
||||
setDayFromFirstWordRecord(daysPassed)
|
||||
}
|
||||
|
||||
fetchDayFromFirstWordRecord()
|
||||
}, [])
|
||||
|
||||
return dayFromFirstWordRecord
|
||||
}
|
||||
|
||||
export function useWordNumber() {
|
||||
const [wordNumber, setWordNumber] = useState<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWordNumber = async () => {
|
||||
const number = await db.wordRecords.count()
|
||||
setWordNumber(number)
|
||||
}
|
||||
|
||||
fetchWordNumber()
|
||||
}, [])
|
||||
|
||||
return wordNumber
|
||||
}
|
||||
|
||||
export function useSumWrongCount() {
|
||||
const [sumWrongCount, setSumWrongCount] = useState<number>(0)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSumWrongCount = async () => {
|
||||
let totalWrongCount = 0
|
||||
|
||||
await db.chapterRecords.each((record) => {
|
||||
totalWrongCount += record.wrongCount || 0
|
||||
})
|
||||
setSumWrongCount(totalWrongCount)
|
||||
}
|
||||
|
||||
fetchSumWrongCount()
|
||||
}, [])
|
||||
|
||||
return sumWrongCount
|
||||
}
|
147
src/components/DonateCard/index.tsx
Normal file
147
src/components/DonateCard/index.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { useChapterNumber, useDayFromFirstWordRecord, useSumWrongCount, useWordNumber } from './hooks/useWordStats'
|
||||
import alipay from '@/assets/alipay.jpg'
|
||||
import weChat from '@/assets/weChat.jpg'
|
||||
import { DONATE_DATE } from '@/constants'
|
||||
import { reportDonateCard } from '@/utils'
|
||||
import noop from '@/utils/noop'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import dayjs from 'dayjs'
|
||||
import type React from 'react'
|
||||
import { Fragment, useLayoutEffect, useMemo, useState } from 'react'
|
||||
import IconParty from '~icons/logos/partytown-icon'
|
||||
|
||||
export const DonateCard = () => {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
const chapterNumber = useChapterNumber()
|
||||
const wordNumber = useWordNumber()
|
||||
const sumWrongCount = useSumWrongCount()
|
||||
const dayFromFirstWord = useDayFromFirstWordRecord()
|
||||
const dayFromQwerty = useMemo(() => {
|
||||
const now = dayjs()
|
||||
const past = dayjs('2021-01-21')
|
||||
return now.diff(past, 'day')
|
||||
}, [])
|
||||
|
||||
const HighlightedText = ({ children, className }: { children: React.ReactNode; className?: string }) => {
|
||||
return <span className={`font-bold ${className ? className : 'text-indigo-500'}`}>{children}</span>
|
||||
}
|
||||
|
||||
const onClickHasDonated = () => {
|
||||
reportDonateCard({
|
||||
type: 'donate',
|
||||
chapterNumber,
|
||||
wordNumber,
|
||||
sumWrongCount,
|
||||
dayFromFirstWord,
|
||||
dayFromQwerty,
|
||||
})
|
||||
|
||||
setShow(false)
|
||||
const now = dayjs()
|
||||
window.localStorage.setItem(DONATE_DATE, now.format())
|
||||
}
|
||||
|
||||
const onClickRemindMeLater = () => {
|
||||
reportDonateCard({
|
||||
type: 'dismiss',
|
||||
chapterNumber,
|
||||
wordNumber,
|
||||
sumWrongCount,
|
||||
dayFromFirstWord,
|
||||
dayFromQwerty,
|
||||
})
|
||||
|
||||
setShow(false)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (chapterNumber && chapterNumber !== 0 && chapterNumber % 10 === 0) {
|
||||
const storedDate = window.localStorage.getItem(DONATE_DATE)
|
||||
const date = dayjs(storedDate)
|
||||
const now = dayjs()
|
||||
const diff = now.diff(date, 'day')
|
||||
if (!storedDate || diff > 30) {
|
||||
setShow(true)
|
||||
}
|
||||
}
|
||||
}, [chapterNumber])
|
||||
|
||||
return (
|
||||
<Transition.Root show={show} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-50"
|
||||
onClose={() => {
|
||||
noop()
|
||||
}}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative my-8 w-[35rem] transform select-text overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all">
|
||||
<div className="flex w-full flex-col justify-center gap-4 bg-white px-2 pb-4 pt-5 dark:bg-gray-800">
|
||||
<h1 className="gradient-text w-full pb-2 pt-3 text-center text-[2.4rem] font-bold">{`${chapterNumber} Chapters Achievement !`}</h1>
|
||||
<div className="flex w-full flex-col gap-4 px-4">
|
||||
<p className="mx-auto px-4 indent-4">
|
||||
您刚刚完成了<HighlightedText> {chapterNumber} </HighlightedText>章节的练习, Qwerty Learner 已经陪你走过
|
||||
<HighlightedText> {dayFromFirstWord} </HighlightedText> 天,一起完成了
|
||||
<HighlightedText> {wordNumber} </HighlightedText>
|
||||
词的练习, 帮助您纠正了 <HighlightedText> {sumWrongCount} </HighlightedText>次错误输入,让我们一起为您的进步欢呼
|
||||
<IconParty className="ml-2 inline-block" fontSize={16} />
|
||||
<IconParty className="inline-block" fontSize={16} />
|
||||
<IconParty className="inline-block" fontSize={16} />
|
||||
<br />
|
||||
</p>
|
||||
<p className="mx-auto px-4 indent-4">
|
||||
Qwerty Learner 已经坚持 <span className="font-medium ">开放源码、无广告、无商业化</span> 运营
|
||||
<HighlightedText className="text-indigo-500"> {dayFromQwerty} </HighlightedText> 天,
|
||||
我们的目标是为所有学习者提供一个高效、便捷、无干扰的学习环境。
|
||||
</p>
|
||||
<p className="mx-auto px-4 indent-4">
|
||||
如果您喜欢 Qwerty, 那么我们诚挚地邀请您考虑进行捐赠,您的捐赠将直接用于维持 Qwerty 的日常运营以及未来的发展,让 Qwerty
|
||||
与您一起成长。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-center pt-6">
|
||||
<img src={alipay} alt="alipay" className="mx-4 w-1/3" />
|
||||
<img src={weChat} alt="weChat" className="mx-4 w-1/3" />
|
||||
</div>
|
||||
<div className="flex w-full justify-between px-14 py-3 ">
|
||||
<button type="button" className="my-btn-primary w-36 bg-amber-500 font-medium" onClick={onClickHasDonated}>
|
||||
我已捐赠
|
||||
</button>
|
||||
<button type="button" className="my-btn-primary w-36 font-medium" onClick={onClickRemindMeLater}>
|
||||
之后提醒我
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
)
|
||||
}
|
@ -4,6 +4,8 @@ export const CHAPTER_LENGTH = 20
|
||||
|
||||
export const DISMISS_START_CARD_DATE_KEY = 'dismissStartCardDate'
|
||||
|
||||
export const DONATE_DATE = 'donateDate'
|
||||
|
||||
export const CONFETTI_DEFAULTS = {
|
||||
colors: ['#5D8C7B', '#F2D091', '#F2A679', '#D9695F', '#8C4646'],
|
||||
shapes: ['square'],
|
||||
|
@ -79,11 +79,11 @@ body,
|
||||
shadow-md dark:bg-gray-800 dark:text-gray-300;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
.my-btn-primary {
|
||||
@apply flex items-center justify-center rounded-lg bg-indigo-400 px-6 py-1 text-lg text-white hover:opacity-90 focus:outline-none dark:text-opacity-80;
|
||||
}
|
||||
|
||||
.btn-info-panel {
|
||||
.my-btn-info-panel {
|
||||
@apply mt-3 inline-flex w-full justify-center rounded-md px-3 py-2 text-sm
|
||||
font-semibold text-white shadow-sm transition-colors focus:outline-none
|
||||
dark:bg-opacity-70 dark:text-opacity-80 sm:ml-3 sm:mt-0 sm:w-auto
|
||||
@ -145,3 +145,20 @@ body,
|
||||
@apply block h-4 w-4 cursor-pointer rounded-full border border-gray-200 bg-white drop-shadow-md;
|
||||
}
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background-image: linear-gradient(90deg, #f66, #f90);
|
||||
background-clip: text;
|
||||
|
||||
color: transparent;
|
||||
animation: gradient-text-hue 5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient-text-hue {
|
||||
from {
|
||||
filter: hue-rotate(0);
|
||||
}
|
||||
to {
|
||||
filter: hue-rotate(-1turn);
|
||||
}
|
||||
}
|
||||
|
@ -247,7 +247,7 @@ const ResultScreen = () => {
|
||||
<div className="mt-10 flex w-full justify-center gap-5 px-5 text-xl">
|
||||
<Tooltip content="快捷键:shift + enter">
|
||||
<button
|
||||
className="btn-primary h-12 border-2 border-solid border-gray-300 bg-white text-base text-gray-700 dark:border-gray-700 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-700"
|
||||
className="my-btn-primary h-12 border-2 border-solid border-gray-300 bg-white text-base text-gray-700 dark:border-gray-700 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-700"
|
||||
type="button"
|
||||
onClick={dictationButtonHandler}
|
||||
title="默写本章节"
|
||||
@ -257,7 +257,7 @@ const ResultScreen = () => {
|
||||
</Tooltip>
|
||||
<Tooltip content="快捷键:space">
|
||||
<button
|
||||
className="btn-primary h-12 border-2 border-solid border-gray-300 bg-white text-base text-gray-700 dark:border-gray-700 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-700"
|
||||
className="my-btn-primary h-12 border-2 border-solid border-gray-300 bg-white text-base text-gray-700 dark:border-gray-700 dark:bg-gray-600 dark:text-white dark:hover:bg-gray-700"
|
||||
type="button"
|
||||
onClick={repeatButtonHandler}
|
||||
title="重复本章节"
|
||||
@ -268,7 +268,7 @@ const ResultScreen = () => {
|
||||
{!isLastChapter && (
|
||||
<Tooltip content="快捷键:enter">
|
||||
<button
|
||||
className={`{ isLastChapter ? 'cursor-not-allowed opacity-50' : ''} btn-primary h-12 text-base font-bold `}
|
||||
className={`{ isLastChapter ? 'cursor-not-allowed opacity-50' : ''} my-btn-primary h-12 text-base font-bold `}
|
||||
type="button"
|
||||
onClick={nextButtonHandler}
|
||||
title="下一章节"
|
||||
|
@ -81,7 +81,7 @@ export default function DataSetting() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-primary ml-4 disabled:bg-gray-300"
|
||||
className="my-btn-primary ml-4 disabled:bg-gray-300"
|
||||
type="button"
|
||||
onClick={onClickExport}
|
||||
disabled={isExporting}
|
||||
@ -110,7 +110,7 @@ export default function DataSetting() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="btn-primary ml-4 disabled:bg-gray-300"
|
||||
className="my-btn-primary ml-4 disabled:bg-gray-300"
|
||||
type="button"
|
||||
onClick={onClickImport}
|
||||
disabled={isImporting}
|
||||
|
@ -79,7 +79,7 @@ export default function ViewSetting() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-primary ml-4 disabled:bg-gray-300" type="button" onClick={onResetFontSize} title="重置字体设置">
|
||||
<button className="my-btn-primary ml-4 disabled:bg-gray-300" type="button" onClick={onResetFontSize} title="重置字体设置">
|
||||
重置字体设置
|
||||
</button>
|
||||
</div>
|
||||
|
@ -145,7 +145,7 @@ export default function SharePicDialog({ showState, setShowState, randomChoose }
|
||||
</div>
|
||||
<button
|
||||
ref={dialogFocusRef}
|
||||
className="btn-primary mr-9 mt-10 h-10"
|
||||
className="my-btn-primary mr-9 mt-10 h-10"
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
title="保存"
|
||||
|
@ -45,7 +45,9 @@ export default function StartButton({ isLoading }: { isLoading: boolean }) {
|
||||
} flex-column absolute left-0 top-0 w-20 rounded-lg shadow-lg transition-colors duration-200`}
|
||||
>
|
||||
<button
|
||||
className={`${state.isTyping ? 'bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-500' : 'bg-indigo-500'} btn-primary w-20 shadow`}
|
||||
className={`${
|
||||
state.isTyping ? 'bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-500' : 'bg-indigo-500'
|
||||
} my-btn-primary w-20 shadow`}
|
||||
type="button"
|
||||
onClick={onToggleIsTyping}
|
||||
aria-label={state.isTyping ? '暂停' : '开始'}
|
||||
@ -57,7 +59,7 @@ export default function StartButton({ isLoading }: { isLoading: boolean }) {
|
||||
<button
|
||||
className={`${
|
||||
state.isTyping ? 'bg-gray-500 dark:bg-gray-700 dark:hover:bg-gray-500 ' : 'bg-indigo-400 '
|
||||
} btn-primary mb-1 mt-1 w-18 transition-colors duration-200`}
|
||||
} my-btn-primary mb-1 mt-1 w-18 transition-colors duration-200`}
|
||||
type="button"
|
||||
onClick={onClickRestart}
|
||||
aria-label={'重新开始'}
|
||||
|
@ -9,6 +9,7 @@ import WordPanel from './components/WordPanel'
|
||||
import { useConfetti } from './hooks/useConfetti'
|
||||
import { useWordList } from './hooks/useWordList'
|
||||
import { TypingContext, TypingStateActionType, initialState, typingReducer } from './store'
|
||||
import { DonateCard } from '@/components/DonateCard'
|
||||
import Header from '@/components/Header'
|
||||
import StarCard from '@/components/StarCard'
|
||||
import Tooltip from '@/components/Tooltip'
|
||||
@ -124,6 +125,7 @@ const App: React.FC = () => {
|
||||
return (
|
||||
<TypingContext.Provider value={{ state: state, dispatch }}>
|
||||
<StarCard />
|
||||
{state.isFinished && <DonateCard />}
|
||||
{state.isFinished && <ResultScreen />}
|
||||
<Layout>
|
||||
<Header>
|
||||
@ -142,7 +144,7 @@ const App: React.FC = () => {
|
||||
<button
|
||||
className={`${
|
||||
state.isShowSkip ? 'bg-orange-400' : 'invisible w-0 bg-gray-300 px-0 opacity-0'
|
||||
} btn-primary transition-all duration-300 `}
|
||||
} my-btn-primary transition-all duration-300 `}
|
||||
onClick={skipWord}
|
||||
>
|
||||
Skip
|
||||
|
@ -55,6 +55,23 @@ export function recordErrorBookAction(type: errorBookType) {
|
||||
mixpanel.track('error-book', props)
|
||||
}
|
||||
|
||||
export type donateCardInfo = {
|
||||
type: 'donate' | 'dismiss'
|
||||
chapterNumber: number
|
||||
wordNumber: number
|
||||
sumWrongCount: number
|
||||
dayFromFirstWord: number
|
||||
dayFromQwerty: number
|
||||
}
|
||||
|
||||
export function reportDonateCard(info: donateCardInfo) {
|
||||
const props = {
|
||||
...info,
|
||||
}
|
||||
|
||||
mixpanel.track('donate-card', props)
|
||||
}
|
||||
|
||||
/**
|
||||
* mixpanel 单词和章节统计事件
|
||||
*/
|
||||
|
@ -10,7 +10,7 @@ import { defineConfig } from 'vite'
|
||||
import type { PluginOption } from 'vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => {
|
||||
export default defineConfig(async ({ mode }) => {
|
||||
const latestCommitHash = await new Promise<string>((resolve) => {
|
||||
return getLastCommit((err, commit) => (err ? 'unknown' : resolve(commit.shortHash)))
|
||||
})
|
||||
@ -34,7 +34,7 @@ export default defineConfig(async () => {
|
||||
sourcemap: false,
|
||||
},
|
||||
esbuild: {
|
||||
drop: ['console', 'debugger'],
|
||||
drop: mode === 'development' ? [] : ['console', 'debugger'],
|
||||
},
|
||||
define: {
|
||||
REACT_APP_DEPLOY_ENV: JSON.stringify(process.env.REACT_APP_DEPLOY_ENV),
|
||||
|
Loading…
Reference in New Issue
Block a user