Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion packages/classic-shared/src/shared.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -59,6 +59,7 @@ export type Game = {
username: string;
};
challengeProgress: PlayerProgress;
hardcoreModeAccess: HardcoreAccessStatus;
};

export type GameResponse = Game;
Expand All @@ -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' }
| {
Expand Down
46 changes: 34 additions & 12 deletions packages/classic-webview/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Page } from '@hotandcold/classic-shared';
import { PlayPage } from './pages/PlayPage';
import { StatsPage } from './pages/StatsPage';
import { WinPage } from './pages/WinPage';
Expand All @@ -8,40 +7,63 @@ import { useGame } from './hooks/useGame';
import { cn } from '@hotandcold/webview-common/utils';
import { Header } from './components/header';
import { LoadingPage } from './pages/LoadingPage';
import UnlockHardcorePage from './pages/UnlockHardcorePage';

export const App = () => {
const page = usePage();
const { mode } = useGame();

const getPage = (page: Page) => {
switch (page) {
case 'play':
return <PlayPage />;
return (
<BasePageLayout hardcore={mode === 'hardcore'}>
Comment thread
cytommi marked this conversation as resolved.
Outdated
<PlayPage />
</BasePageLayout>
);
case 'stats':
return <StatsPage />;
return (
<BasePageLayout hardcore={mode === 'hardcore'}>
<StatsPage />
</BasePageLayout>
);
case 'win':
return <WinPage />;
return (
<BasePageLayout hardcore={mode === 'hardcore'}>
<WinPage />
</BasePageLayout>
);
case 'loading':
return <LoadingPage />;
return (
<BasePageLayout hardcore={mode === 'hardcore'}>
<LoadingPage />;
</BasePageLayout>
);
case 'unlock-hardcore':
return <UnlockHardcorePage />;
default:
throw new Error(`Invalid page: ${String(page satisfies never)}`);
}
};

export const App = () => {
const page = usePage();
const { mode } = useGame();
type BasePageLayoutProps = {
hardcore: boolean;
children: React.ReactNode;
};

const BasePageLayout = (props: BasePageLayoutProps) => {
return (
<div
className={cn(
'relative flex h-full min-h-0 flex-1 flex-col p-6',
mode === 'hardcore' &&
props.hardcore &&
'bg-[url(/assets/hardcore_background.png)] bg-cover bg-center bg-no-repeat bg-blend-multiply'
)}
>
<div className="mb-4 sm:mb-6">
<Header />
</div>
{getPage(page)}
{props.children}
<Progress />
{/* <FriendsModal isOpen={friendsModalOpen} onClose={() => setFriendsModalOpen(false)} /> */}
</div>
);
};
9 changes: 7 additions & 2 deletions packages/classic-webview/src/hooks/useGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
12 changes: 10 additions & 2 deletions packages/classic-webview/src/hooks/usePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
79 changes: 79 additions & 0 deletions packages/classic-webview/src/pages/UnlockHardcorePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React from 'react';
import { HardcoreLogo } from '@hotandcold/webview-common/components/logo';
import { GoldIcon } from '@hotandcold/webview-common/components/icon';

const primaryBorderColor = '#FFBF0B';
const secondaryBorderColor = '#8BA2AD';
const secondaryPillBgColor = '#2A3236';
Comment thread
cytommi marked this conversation as resolved.
Outdated

// Inline PurchaseButton component
interface PurchaseButtonProps {
mainText: string;
style: 'primary' | 'secondary';
badgeIcon: React.ReactNode;
badgeText: string;
onClick?: () => void;
}

const PurchaseButton: React.FC<PurchaseButtonProps> = (props) => {
const { mainText, badgeIcon, badgeText, onClick, style } = props;
const color = style === 'primary' ? primaryBorderColor : secondaryBorderColor;
const pillBg = style === 'secondary' ? secondaryPillBgColor : color;
const pillTextColor = style === 'secondary' ? 'white' : 'black';
Comment thread
cytommi marked this conversation as resolved.
Outdated

return (
<button
onClick={onClick}
className="flex w-full flex-auto flex-row items-center justify-between gap-4 whitespace-nowrap rounded-[64px] border-2 p-3 sm:w-auto"
Comment thread
cytommi marked this conversation as resolved.
Outdated
style={{ borderColor: color }}
Comment thread
cytommi marked this conversation as resolved.
Outdated
>
<span
className="text-left font-sans text-base font-semibold leading-5 tracking-tight"
style={{ color }}
Comment thread
cytommi marked this conversation as resolved.
Outdated
>
{mainText}
</span>
<span
className="flex h-8 w-fit flex-row items-center gap-1 rounded-full px-3 py-2 text-xs font-semibold"
Comment thread
cytommi marked this conversation as resolved.
Outdated
style={{ backgroundColor: pillBg, color: pillTextColor }}
>
<span className="h-4 w-4">{badgeIcon}</span>
<span className="text-center leading-4 tracking-[-0.1px]">{badgeText}</span>
Comment thread
cytommi marked this conversation as resolved.
Outdated
</span>
</button>
);
};

export const UnlockHardcorePage: React.FC = () => {
return (
<div className="flex min-h-screen items-center justify-center bg-[#0E1113] p-4 text-center">
<div className="flex w-full max-w-md flex-col items-center justify-center gap-4 rounded-lg bg-[#0E1113] p-6 shadow-lg sm:gap-6 sm:p-8 md:max-w-2xl md:p-10">
Comment thread
cytommi marked this conversation as resolved.
Outdated
<HardcoreLogo />
<p className="font-sans text-xl font-bold leading-tight tracking-normal text-white sm:text-2xl sm:leading-7">
100 guesses. No hints. No mercy.
</p>
Comment thread
cytommi marked this conversation as resolved.
Outdated
<p className="text-center font-sans text-sm font-normal leading-5 tracking-tight text-gray-300">
Comment thread
cytommi marked this conversation as resolved.
Outdated
Unlocking Hardcore grants access to today and all previous hardcore puzzles.
</p>
<div className="h-px w-1/2 max-w-xs bg-white/20"></div>
Comment thread
cytommi marked this conversation as resolved.
Outdated
<div className="flex w-full flex-col items-center gap-4 py-2 sm:w-auto sm:flex-row sm:items-start">
Comment thread
cytommi marked this conversation as resolved.
Outdated
<PurchaseButton
mainText="Unlock for 7 days"
Comment thread
cytommi marked this conversation as resolved.
Outdated
badgeIcon={<GoldIcon />}
Comment thread
cytommi marked this conversation as resolved.
Outdated
badgeText="Use 50"
Comment thread
cytommi marked this conversation as resolved.
Outdated
style="secondary"
/>
<PurchaseButton
mainText="Unlock FOREVER"
badgeIcon={<GoldIcon />}
badgeText="Use 250"
style="primary"
/>
</div>
</div>
</div>
);
};

// Default export is often helpful for page components
export default UnlockHardcorePage;
Comment thread
cytommi marked this conversation as resolved.
Outdated
Binary file added packages/classic/assets/product_icon_7_days.png
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sneaking in product images with correct sizes in this PR

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packages/classic/assets/product_icon_forever.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed packages/classic/assets/unlock-forever.png
Binary file not shown.
Binary file removed packages/classic/assets/unlock-seven-days.png
Binary file not shown.
1 change: 1 addition & 0 deletions packages/classic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@devvit/payments": "0.11.12",
"@devvit/public-api": "0.11.12",
"@hotandcold/classic-shared": "*",
"@hotandcold/shared": "*",
Expand Down
21 changes: 8 additions & 13 deletions packages/classic/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { Guess } from './core/guess.js';
import { ChallengeToPost } from './core/challengeToPost.js';
import { Preview } from './components/Preview.js';
Expand All @@ -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();

Expand Down Expand Up @@ -56,6 +56,7 @@ type InitialState =
challengeInfo: Awaited<ReturnType<ChallengeService['getChallenge']>>;
challengeUserInfo: Awaited<ReturnType<(typeof Guess)['getChallengeUserInfo']>>;
challengeProgress: Awaited<ReturnType<(typeof ChallengeProgress)['getPlayerProgress']>>;
hardcoreModeAccess: HardcoreAccessStatus;
};

// Add a post type definition
Expand All @@ -64,13 +65,15 @@ Devvit.addCustomPostType({
height: 'tall',
render: (context) => {
const challengeService = new ChallengeService(context.redis);
const paymentsRepo = new PaymentsRepo(context.redis);
const [initialState] = useState<InitialState>(async () => {
const [user, challenge] = await Promise.all([
const [user, challenge, hardcoreModeAccess] = await Promise.all([
context.reddit.getCurrentUser(),
ChallengeToPost.getChallengeNumberForPost({
redis: context.redis,
postId: context.postId!,
}),
paymentsRepo.getHardcoreAccessStatus(context.userId!),
]);
if (!user) {
return {
Expand Down Expand Up @@ -104,23 +107,14 @@ Devvit.addCustomPostType({
}),
]);

// sendMessageToWebview(context, {
// type: 'INIT',
// payload: {
// challengeInfo: omit(challengeInfo, ['word']),
// challengeUserInfo,
// number: challenge,
// challengeProgress: challengeProgress,
// },
// });

return {
type: 'AUTHED' as const,
user: { username: user.username, avatar },
challenge,
challengeInfo,
challengeUserInfo,
challengeProgress,
hardcoreModeAccess,
};
});

Expand Down Expand Up @@ -170,6 +164,7 @@ Devvit.addCustomPostType({
challengeUserInfo,
number: challenge,
challengeProgress: challengeProgress,
hardcoreModeAccess: initialState.hardcoreModeAccess,
},
});

Expand Down
23 changes: 22 additions & 1 deletion packages/classic/src/payments.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
Expand Down Expand Up @@ -42,6 +43,26 @@ class PaymentsRepo {
newExpiry.valueOf().toString()
);
}

async getHardcoreAccessStatus(userId: string): Promise<HardcoreAccessStatus> {
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() {
Expand Down
4 changes: 2 additions & 2 deletions packages/classic/src/products.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"price": 250,
"accountingType": "DURABLE",
"images": {
"icon": "unlock-forever.png"
"icon": "product_icon_forever.png"
}
},
{
Expand All @@ -18,7 +18,7 @@
"price": 50,
"accountingType": "VALID_FOR_7D",
"images": {
"icon": "unlock-seven-days.png"
"icon": "product_icon_7_days.png"
}
}
]
Expand Down
Loading