diff --git a/package-lock.json b/package-lock.json index 0ec4c324..6a655ac9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11133,7 +11133,8 @@ "version": "0.0.0", "license": "BSD-3-Clause", "dependencies": { - "@devvit/public-api": "^0.11.13", + "@devvit/payments": "0.11.12", + "@devvit/public-api": "0.11.12", "@hotandcold/classic-shared": "*", "@hotandcold/shared": "*", "luxon": "^3.6.1" @@ -11194,6 +11195,69 @@ "node": ">=14.17" } }, + "packages/classic/node_modules/@devvit/metrics": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/@devvit/metrics/-/metrics-0.11.12.tgz", + "integrity": "sha512-vQ12EK+1fmf0ivseLgWsjW5o1TDT/L1oCN/XJotV6ca85bToqu3VQFtNOjzRkC/9CUeHbRRFZXwfPpKf5m2EJQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/protos": "0.11.12" + } + }, + "packages/classic/node_modules/@devvit/payments": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/@devvit/payments/-/payments-0.11.12.tgz", + "integrity": "sha512-AsBkynEn0pmHv9a8tCB9e2mBcrqjtujOJMcLMe0bGHNerjPtN11f9XVnN4t8lBi9ArLTKMmQo7HEzJSBTa4U5Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/protos": "0.11.12", + "@devvit/public-api": "0.11.12", + "@devvit/shared-types": "0.11.12" + } + }, + "packages/classic/node_modules/@devvit/protos": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/@devvit/protos/-/protos-0.11.12.tgz", + "integrity": "sha512-SMPKHhhsdAT8sv8Ps/Z9stWQU/Sl365CMoiG3eSxRUKJ7tGGAqCDDwGJgaLZB5U7wGPg6Zkt9+JB70RFdypyvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "protobufjs": "7.3.2", + "rxjs": "7.8.1" + }, + "peerDependencies": { + "twirp-ts": "^2.5.0" + }, + "peerDependenciesMeta": { + "twirp-ts": { + "optional": true + } + } + }, + "packages/classic/node_modules/@devvit/public-api": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/@devvit/public-api/-/public-api-0.11.12.tgz", + "integrity": "sha512-kIYLp7mDHHBqneBfeGtTieW9cY+32WQpz++cEJjViQOhtSzmd0yFhfWdILfjPnDD/+vTKHZJChF0CdodGgt48A==", + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/metrics": "0.11.12", + "@devvit/protos": "0.11.12", + "@devvit/shared-types": "0.11.12", + "base64-js": "1.5.1", + "clone-deep": "4.0.1", + "moderndash": "4.0.0" + } + }, + "packages/classic/node_modules/@devvit/shared-types": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/@devvit/shared-types/-/shared-types-0.11.12.tgz", + "integrity": "sha512-ZbMCijsckqg+PIcHffKd4AKMAHXS50ht2CdgXzmuSLh7PR02LySP94Wq7PpT4sNMAV/d5rNt/pax8/PHuobvkg==", + "license": "BSD-3-Clause", + "dependencies": { + "@devvit/protos": "0.11.12", + "jsonschema": "1.4.1", + "uuid": "9.0.0" + } + }, "packages/raid": { "name": "@hotandcold/raid", "version": "0.0.0", diff --git a/packages/classic-shared/src/shared.ts b/packages/classic-shared/src/shared.ts index fa6321ae..8c88d5dd 100644 --- a/packages/classic-shared/src/shared.ts +++ b/packages/classic-shared/src/shared.ts @@ -1,4 +1,4 @@ -export type Page = 'loading' | 'play' | 'stats' | 'win'; +export type Page = 'loading' | 'play' | 'stats' | 'win' | 'unlock-hardcore'; export type Guess = { word: string; @@ -59,6 +59,7 @@ export type Game = { username: string; }; challengeProgress: PlayerProgress; + hardcoreModeAccess: HardcoreAccessStatus; }; export type GameResponse = Game; @@ -80,6 +81,11 @@ export type ChallengeLeaderboardResponse = { leaderboardByFastest: { member: string; score: number }[]; }; +export type HardcoreAccessStatus = + | { status: 'lifetime' } + | { status: 'active'; expires: number } + | { status: 'inactive' }; + export type WebviewToBlocksMessage = | { type: 'GAME_INIT' } | { diff --git a/packages/classic-webview/src/App.tsx b/packages/classic-webview/src/App.tsx index ba316a05..70f7d0d1 100644 --- a/packages/classic-webview/src/App.tsx +++ b/packages/classic-webview/src/App.tsx @@ -9,6 +9,11 @@ import { cn } from '@hotandcold/webview-common/utils'; import { Header } from './components/header'; import { LoadingPage } from './pages/LoadingPage'; +import { useModal } from './hooks/useModal'; +import { UnlockHardcoreModal } from './components/UnlockHardcoreModal'; +import { HowToPlayModal } from './components/howToPlayModal'; +import { ScoreBreakdownModal } from './components/scoreBreakdownModal'; + const getPage = (page: Page) => { switch (page) { case 'play': @@ -19,6 +24,9 @@ const getPage = (page: Page) => { return ; case 'loading': return ; + case 'unlock-hardcore': + // TODO: Implement page for hardcore mode + return
UNLOCK HARDCORD
; default: throw new Error(`Invalid page: ${String(page satisfies never)}`); } @@ -27,6 +35,18 @@ const getPage = (page: Page) => { export const App = () => { const page = usePage(); const { mode } = useGame(); + const { modal, closeModal } = useModal(); + + if (modal != null) { + switch (modal) { + case 'unlock-hardcore': + return ; + case 'how-to-play': + return ; + case 'score-breakdown': + return ; + } + } return (
{
{getPage(page)} - {/* setFriendsModalOpen(false)} /> */} ); }; diff --git a/packages/classic-webview/src/components/UnlockHardcoreModal.tsx b/packages/classic-webview/src/components/UnlockHardcoreModal.tsx new file mode 100644 index 00000000..5d8157f1 --- /dev/null +++ b/packages/classic-webview/src/components/UnlockHardcoreModal.tsx @@ -0,0 +1,64 @@ +import React, { ComponentProps } from 'react'; +import { HardcoreLogo } from '@hotandcold/webview-common/components/logo'; +import { GoldIcon } from '@hotandcold/webview-common/components/icon'; +import { cn } from '@hotandcold/webview-common/utils'; +import { Modal } from '@hotandcold/webview-common/components/modal'; + +interface PurchaseButtonProps { + children: React.ReactNode; + style: 'primary' | 'secondary'; + price: number; + onClick?: () => void; +} + +const PurchaseButton: React.FC = (props) => { + const { children, price, onClick, style } = props; + + return ( + + ); +}; + +type UnlockHardcoreModalProps = Omit, 'children'>; + +export const UnlockHardcoreModal = (props: UnlockHardcoreModalProps) => { + return ( + +
+ +

100 guesses. No hints. No mercy.

+

+ Unlocking Hardcore grants access to today and all previous hardcore puzzles. +

+
+
+ + Unlock for 7 days + + + Unlock FOREVER + +
+
+
+ ); +}; diff --git a/packages/classic-webview/src/components/header.tsx b/packages/classic-webview/src/components/header.tsx index bc331659..8d3b4c01 100644 --- a/packages/classic-webview/src/components/header.tsx +++ b/packages/classic-webview/src/components/header.tsx @@ -4,11 +4,10 @@ import { useConfirmation } from '@hotandcold/webview-common/hooks/useConfirmatio import { sendMessageToDevvit } from '../utils'; import { useUserSettings, useSetUserSettings } from '../hooks/useUserSettings'; import { useGame } from '../hooks/useGame'; -import { useState } from 'react'; import type { UserSettings } from '@hotandcold/classic-shared'; -import { HowToPlayModal } from './howToPlayModal'; import { IconButton } from '@hotandcold/webview-common/components/button'; import { InfoIcon } from '@hotandcold/webview-common/components/icon'; +import { useModal } from '../hooks/useModal'; const SpeechBubbleTail = ({ className }: { className?: string }) => ( { !challengeUserInfo?.solvedAtMs && !challengeUserInfo?.gaveUpAtMs; const { showConfirmation } = useConfirmation(); - const [howToPlayOpen, setHowToPlayOpen] = useState(false); + + const { showModal } = useModal(); const isHardcore = mode === 'hardcore'; @@ -59,7 +59,7 @@ export const Header = () => {
setHowToPlayOpen(true)} + onClick={() => showModal('how-to-play')} icon={} aria-label="How to Play" > @@ -133,7 +133,6 @@ export const Header = () => { />
- setHowToPlayOpen(false)} /> ); }; diff --git a/packages/classic-webview/src/hooks/useGame.tsx b/packages/classic-webview/src/hooks/useGame.tsx index c8fa4fab..1a429a65 100644 --- a/packages/classic-webview/src/hooks/useGame.tsx +++ b/packages/classic-webview/src/hooks/useGame.tsx @@ -65,9 +65,14 @@ export const GameContextProvider = ({ children }: { children: React.ReactNode }) if (isEmpty(game)) return; - // Keep in sync with usePage's initializer - if (game.challengeUserInfo?.solvedAtMs || game.challengeUserInfo?.gaveUpAtMs) { + if ( + // Keep in sync with usePage's initializer + game.challengeUserInfo?.solvedAtMs || + game.challengeUserInfo?.gaveUpAtMs + ) { setPage('win'); + } else if (game.hardcoreModeAccess?.status === 'inactive' && game.mode === 'hardcore') { + setPage('unlock-hardcore'); } else { setPage('play'); } diff --git a/packages/classic-webview/src/hooks/useModal.tsx b/packages/classic-webview/src/hooks/useModal.tsx new file mode 100644 index 00000000..b9d07425 --- /dev/null +++ b/packages/classic-webview/src/hooks/useModal.tsx @@ -0,0 +1,51 @@ +import { createContext, useContext, useState } from 'react'; +import { UnlockHardcoreModal } from '../components/UnlockHardcoreModal'; +import { HowToPlayModal } from '../components/howToPlayModal'; +import { ScoreBreakdownModal } from '../components/scoreBreakdownModal'; + +export type ModalType = 'unlock-hardcore' | 'how-to-play' | 'score-breakdown'; + +type ModalContext = { + modal: ModalType | undefined; + /** set to `undefined` to indicate that no modal is showing */ + showModal: (m: ModalType) => void; + closeModal: () => void; +}; + +const modalContext = createContext(null); + +export const ModalContextProvider = (props: { children: React.ReactNode }) => { + const [modal, setModal] = useState(undefined); + + const closeModal = () => setModal(undefined); + + const Modal: React.FC = () => { + switch (modal) { + case 'unlock-hardcore': + return ; + case 'how-to-play': + return ; + case 'score-breakdown': + return ; + default: + return <>; + } + }; + + return ( + setModal(m), closeModal }} + > + {modal && } + {props.children} + + ); +}; + +export const useModal = () => { + const context = useContext(modalContext); + if (context === null) { + throw new Error('useModal must be used within a ModalContextProvider'); + } + return context; +}; diff --git a/packages/classic-webview/src/hooks/usePage.tsx b/packages/classic-webview/src/hooks/usePage.tsx index 4a398be5..05ae0d07 100644 --- a/packages/classic-webview/src/hooks/usePage.tsx +++ b/packages/classic-webview/src/hooks/usePage.tsx @@ -10,15 +10,23 @@ export const PageContextProvider = ({ children }: { children: React.ReactNode }) if (!GAME_INIT_DATA) { return 'loading'; } + // Keep in sync with useGame's use effect if ( GAME_INIT_DATA.challengeUserInfo?.solvedAtMs || GAME_INIT_DATA.challengeUserInfo?.gaveUpAtMs ) { return 'win'; - } else { - return 'play'; } + + if ( + GAME_INIT_DATA.mode === 'hardcore' && + GAME_INIT_DATA.hardcoreModeAccess.status === 'inactive' + ) { + return 'unlock-hardcore'; + } + + return 'play'; }); return ( diff --git a/packages/classic-webview/src/main.tsx b/packages/classic-webview/src/main.tsx index 3170ee20..efcf6d80 100644 --- a/packages/classic-webview/src/main.tsx +++ b/packages/classic-webview/src/main.tsx @@ -10,6 +10,7 @@ import { UserSettingsContextProvider } from './hooks/useUserSettings'; import { MockProvider } from './hooks/useMocks'; import { ConfirmationDialogProvider } from '@hotandcold/webview-common/hooks/useConfirmation'; import { IS_DETACHED } from './constants'; +import { ModalContextProvider } from './hooks/useModal'; console.log('webview main called'); @@ -21,13 +22,15 @@ createRoot(document.getElementById('root')!).render( - - - - - - - + + + + + + + + + diff --git a/packages/classic-webview/src/pages/WinPage.tsx b/packages/classic-webview/src/pages/WinPage.tsx index 03331a9b..a9a14bb3 100644 --- a/packages/classic-webview/src/pages/WinPage.tsx +++ b/packages/classic-webview/src/pages/WinPage.tsx @@ -5,7 +5,7 @@ import { cn, getPrettyDuration } from '@hotandcold/webview-common/utils'; import { useDevvitListener } from '../hooks/useDevvitListener'; import { Tablist } from '@hotandcold/webview-common/components/tablist'; import { useUserSettings } from '../hooks/useUserSettings'; -import { ScoreBreakdownModal } from '../components/scoreBreakdownModal'; +import { useModal } from '../hooks/useModal'; import { GradientBorder } from '@hotandcold/webview-common/components/gradientBorder'; import { RightChevronIcon } from '@hotandcold/webview-common/components/icon'; @@ -35,7 +35,7 @@ export const WinPage = () => { const { challengeInfo, challengeUserInfo } = useGame(); const [activeIndex, setActiveIndex] = React.useState(0); const { isUserOptedIntoReminders } = useUserSettings(); - const [isScoreBreakdownOpen, setIsScoreBreakdownOpen] = React.useState(false); + const { showModal: setModal } = useModal(); const leaderboardData = useDevvitListener('CHALLENGE_LEADERBOARD_RESPONSE'); if (!challengeUserInfo || !challengeInfo) return null; @@ -94,7 +94,7 @@ export const WinPage = () => { ( @@ -253,11 +253,6 @@ export const WinPage = () => { )} - setIsScoreBreakdownOpen(false)} - /> ); }; diff --git a/packages/classic-webview/tailwind.config.js b/packages/classic-webview/tailwind.config.js index 51b2f792..5f3a5b94 100644 --- a/packages/classic-webview/tailwind.config.js +++ b/packages/classic-webview/tailwind.config.js @@ -7,7 +7,14 @@ export default { '../webview-common/src/**/*.{js,ts,jsx,tsx}', ], theme: { - extend: {}, + extend: { + colors: { + 'mustard-gold': '#FFBF0B', + 'slate-gray': '#8BA2AD', + 'charcoal': '#2A3236', + 'night': '#0E1113' + } + }, }, plugins: [], }; diff --git a/packages/classic/assets/product_icon_7_days.png b/packages/classic/assets/product_icon_7_days.png new file mode 100644 index 00000000..f040f707 Binary files /dev/null and b/packages/classic/assets/product_icon_7_days.png differ diff --git a/packages/classic/assets/product_icon_forever.png b/packages/classic/assets/product_icon_forever.png new file mode 100644 index 00000000..27abaee2 Binary files /dev/null and b/packages/classic/assets/product_icon_forever.png differ diff --git a/packages/classic/package.json b/packages/classic/package.json index d7e09fc1..b1827417 100644 --- a/packages/classic/package.json +++ b/packages/classic/package.json @@ -13,7 +13,8 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@devvit/public-api": "^0.11.13", + "@devvit/payments": "0.11.12", + "@devvit/public-api": "0.11.12", "@hotandcold/classic-shared": "*", "@hotandcold/shared": "*", "luxon": "^3.6.1" diff --git a/packages/classic/src/main.tsx b/packages/classic/src/main.tsx index 84c1501c..3fab7196 100644 --- a/packages/classic/src/main.tsx +++ b/packages/classic/src/main.tsx @@ -9,7 +9,7 @@ import './menu-actions/totalReminders.js'; import { Devvit, useInterval, useState } from '@devvit/public-api'; import { DEVVIT_SETTINGS_KEYS } from './constants.js'; import { isServerCall, omit } from '@hotandcold/shared/utils'; -import { WebviewToBlocksMessage } from '@hotandcold/classic-shared'; +import { HardcoreAccessStatus, WebviewToBlocksMessage } from '@hotandcold/classic-shared'; import { GuessService } from './core/guess.js'; import { ChallengeToPost, PostIdentifier } from './core/challengeToPost.js'; import { Preview } from './components/Preview.js'; @@ -19,7 +19,7 @@ import { ChallengeLeaderboard } from './core/challengeLeaderboard.js'; import { Reminders } from './core/reminders.js'; import { RedditApiCache } from './core/redditApiCache.js'; import { sendMessageToWebview } from './utils/index.js'; -import { initPayments } from './payments.js'; +import { initPayments, PaymentsRepo } from './payments.js'; initPayments(); @@ -56,6 +56,7 @@ type InitialState = challengeInfo: Awaited>; challengeUserInfo: Awaited>; challengeProgress: Awaited>; + hardcoreModeAccess: HardcoreAccessStatus; }; // Add a post type definition @@ -80,9 +81,14 @@ Devvit.addCustomPostType({ const challengeService = new ChallengeService(context.redis, gameMode); const guessService = new GuessService(context.redis, gameMode, context); const challengeProgressService = new ChallengeProgressService(context, gameMode); + const paymentsRepo = new PaymentsRepo(context.redis); const [initialState] = useState(async () => { - const user = await context.reddit.getCurrentUser(); + const [user, hardcoreModeAccess] = await Promise.all([ + context.reddit.getCurrentUser(), + paymentsRepo.getHardcoreAccessStatus(context.userId!), + ]); + if (!user) { return { type: 'UNAUTHED' as const, @@ -120,6 +126,7 @@ Devvit.addCustomPostType({ challengeInfo, challengeUserInfo, challengeProgress, + hardcoreModeAccess, }; }); @@ -168,6 +175,7 @@ Devvit.addCustomPostType({ challengeUserInfo, number: challenge, challengeProgress: challengeProgress, + hardcoreModeAccess: initialState.hardcoreModeAccess, }, }); diff --git a/packages/classic/src/payments.ts b/packages/classic/src/payments.ts index bf125f88..a3fb7dea 100644 --- a/packages/classic/src/payments.ts +++ b/packages/classic/src/payments.ts @@ -1,8 +1,9 @@ import { addPaymentHandler } from '@devvit/payments'; import { type RedisClient } from '@devvit/public-api'; +import { HardcoreAccessStatus } from '@hotandcold/classic-shared'; import { DateTime } from 'luxon'; -class PaymentsRepo { +export class PaymentsRepo { static hardcoreModeAccessKey(userId: string) { return `hardcore-mode-access:${userId}`; } @@ -42,6 +43,26 @@ class PaymentsRepo { newExpiry.valueOf().toString() ); } + + async getHardcoreAccessStatus(userId: string): Promise { + const key = PaymentsRepo.hardcoreModeAccessKey(userId); + const currentAccess = await this.#redis.get(key); + + if (currentAccess === '-1') { + return { status: 'lifetime' }; + } + + if (!currentAccess) { + return { status: 'inactive' }; + } + + const expiryMillis = Number(currentAccess); + const expiryDate = DateTime.fromMillis(expiryMillis); + if (expiryDate <= DateTime.now()) { + return { status: 'inactive' }; + } + return { status: 'active', expires: expiryDate.valueOf() }; + } } export function initPayments() { diff --git a/packages/classic/src/products.json b/packages/classic/src/products.json index 91bb1964..c846b1e7 100644 --- a/packages/classic/src/products.json +++ b/packages/classic/src/products.json @@ -6,14 +6,20 @@ "displayName": "Unlock HARDCORE (forever)", "description": "Unlocks HARDCORE mode indefinitely. Takes effect immediately. HARDCORE puzzles are limited to 100 guesses and no hints.", "price": 250, - "accountingType": "DURABLE" + "accountingType": "DURABLE", + "images": { + "icon": "product_icon_forever.png" + } }, { "sku": "hardcore-mode-seven-day-access", "displayName": "Unlock HARDCORE (7 days)", "description": "Unlocks HARDCORE mode for 7 days. Takes effect immediately. HARDCORE puzzles are limited to 100 guesses and no hints.", "price": 50, - "accountingType": "VALID_FOR_7D" + "accountingType": "VALID_FOR_7D", + "images": { + "icon": "product_icon_7_days.png" + } } ] } diff --git a/packages/webview-common/src/components/icon.tsx b/packages/webview-common/src/components/icon.tsx index 769ca839..9e5a2688 100644 --- a/packages/webview-common/src/components/icon.tsx +++ b/packages/webview-common/src/components/icon.tsx @@ -10,6 +10,15 @@ export const HamburgerIcon = () => ( ); +export const GoldIcon = () => ( + + + +); + export const RightChevronIcon = () => (