Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

v3.4 Taboo AI Gems & Shops #342

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ body > main {
@apply w-full flex-grow overflow-y-auto scroll-smooth;
}

body * {
@apply cursor-default !important;
}

@layer base {
* {
@apply border-border;
Expand Down
1 change: 1 addition & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export const viewport: Viewport = {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const maintenanceMode = JSON.parse(process.env.NEXT_PUBLIC_MAINTENANCE || 'false');
const user = (await fetchUserProfile()) ?? undefined;

return (
<ReactQueryProvider>
<html lang='en' suppressHydrationWarning>
Expand Down
12 changes: 6 additions & 6 deletions app/outgoing-links-carousel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
'use client';

import { useRef } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import Autoplay from 'embla-carousel-autoplay';
import AutoPlay from 'embla-carousel-autoplay';

import {
Carousel,
Expand All @@ -13,15 +14,14 @@ import {
} from '@/components/ui/carousel';

export function OutgoingLinksCarousel() {
const autoplay = useRef(AutoPlay({ stopOnInteraction: true, delay: 5000 }));
return (
<Carousel
opts={{ loop: true }}
plugins={[
Autoplay({
delay: 5000,
}),
]}
plugins={[autoplay.current]}
className='flex w-full items-center justify-center py-4'
onPointerEnter={() => autoplay.current.stop()}
onPointerLeave={() => autoplay.current.play()}
>
<CarouselPrevious className='!relative !left-0 !top-0 !flex !aspect-square !translate-x-0 !translate-y-0 !rounded-lg !border-none' />
<CarouselContent>
Expand Down
9 changes: 9 additions & 0 deletions app/shop/checkout-success/[checkout_session_id]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { TabooAILoadingEffect } from '@/components/custom/globals/taboo-ai-loading-effect';

export default function Loading() {
return (
<section className='flex h-full w-full items-center justify-center p-4'>
<TabooAILoadingEffect />
</section>
);
}
88 changes: 88 additions & 0 deletions app/shop/checkout-success/[checkout_session_id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { Gem } from 'lucide-react';

import { fetchUserProfile } from '@/app/profile/server/fetch-user-profile';
import { Confetti } from '@/components/custom/confetti';
import { Button } from '@/components/ui/button';

import { getPrices } from '../../server/get-prices';
import { retrieveCheckoutSession } from '../../server/retrieve-checkout-session';
import { retrieveCheckoutSessionLineItems } from '../../server/retrieve-checkout-session-line-items';
import { updateUserTokens } from '../../server/update-user-tokens';
import { fetchCheckoutHistory } from './server/fetch-checkout-history';
import { saveCheckoutHistory } from './server/save-checkout-history';

type CheckoutSuccessPageProps = {
params: {
checkout_session_id: string;
};
};

export default async function CheckoutSuccessPage({ params }: CheckoutSuccessPageProps) {
const user = await fetchUserProfile();
if (!user) redirect('/sign-in');

const checkoutSession = await retrieveCheckoutSession(params.checkout_session_id);
if (!checkoutSession) throw new Error('Checkout session not found');

if (checkoutSession.status !== 'complete') throw new Error('Checkout session not completed');
if (checkoutSession.payment_status !== 'paid') throw new Error('Checkout session not paid');

const { lineItems } = await retrieveCheckoutSessionLineItems(params.checkout_session_id);

const priceId = lineItems.data.at(0)?.price?.id;
if (!priceId) throw new Error('Price ID not found in checkout session');

const prices = await getPrices(priceId);
const price = prices.at(0);
if (!price) throw new Error('Price not found');

const tokens = parseInt(price.metadata.tokens_granted);
if (isNaN(tokens)) throw new Error('Invalid tokens granted');

// Check if checkout session already exists in user's checkout history.
const { checkoutHistory } = await fetchCheckoutHistory({
checkoutSessionId: params.checkout_session_id,
});
if (checkoutHistory === null) {
// Save checkout history and update user tokens
await saveCheckoutHistory({
userId: user.id,
checkoutSessionId: params.checkout_session_id,
priceId: priceId,
price: price.unitDollar,
tokens,
});

// Update user tokens
await updateUserTokens({ tokens });

// Refresh the page
redirect(`/shop/checkout-success/${params.checkout_session_id}`);
}

// Refresh user profile
const updatedUser = await fetchUserProfile();
if (!updatedUser) throw new Error('User not found');

return (
<main className='flex h-screen w-full items-start justify-center pt-24'>
<div className='flex flex-col items-center justify-center gap-y-6'>
<h2 className='text-2xl font-bold'>You&apos;re all set!</h2>
<div className='flex flex-col items-center justify-center gap-y-2 rounded-lg border p-6'>
<p>You now have</p>
<div className='flex items-center gap-x-2'>
<span className='text-4xl font-bold'>{updatedUser.tokens}</span>
<Gem className='size-8' />
</div>
</div>

<Link href='/levels'>
<Button>Continue playing</Button>
</Link>
</div>
<Confetti />
</main>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import 'server-only';

import { createServiceRoleClient } from '@/lib/utils/supabase/service-role';

type FetchCheckoutHistoryOptions = {
checkoutSessionId: string;
};

export async function fetchCheckoutHistory({ checkoutSessionId }: FetchCheckoutHistoryOptions) {
const supabase = createServiceRoleClient();
const { data, error } = await supabase
.from('users_checkout_history')
.select()
.eq('checkout_session_id', checkoutSessionId)
.maybeSingle();
if (error) throw error.message;

return { checkoutHistory: data };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import 'server-only';

import { createServiceRoleClient } from '@/lib/utils/supabase/service-role';

type SaveCheckoutHistoryOptions = {
userId: string;
checkoutSessionId: string;
priceId: string;
price: number;
tokens: number;
};

export async function saveCheckoutHistory(options: SaveCheckoutHistoryOptions) {
const supabase = createServiceRoleClient();
const { data, error } = await supabase
.from('users_checkout_history')
.insert({
user_id: options.userId,
checkout_session_id: options.checkoutSessionId,
price_id: options.priceId,
price: options.price,
tokens: options.tokens,
})
.select()
.single();
if (error) throw error.message;

return { checkoutHistory: data };
}
79 changes: 79 additions & 0 deletions app/shop/diamond-scene.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use client';

import { Suspense, useEffect, useRef } from 'react';
import { OrbitControls, useGLTF } from '@react-three/drei';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three';
import { BloomPass } from 'three/examples/jsm/postprocessing/BloomPass';
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer';
import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass';

// Load the GLTF model
const DiamondModel = () => {
const { scene } = useGLTF('/models/diamond.glb'); // Path to your model in the public directory

// Apply reflective purple material to the entire model
scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material = new THREE.MeshPhysicalMaterial({
color: new THREE.Color(0x800090), // Purple color
metalness: 0.9,
roughness: 0.4,
envMapIntensity: 1,
clearcoat: 0.5,
clearcoatRoughness: 0.1,
});
}
});

return <primitive object={scene} scale={[0.3, 0.38, 0.3]} position={[0, -1, 0]} />;
};

const Effects = () => {
const { gl, scene, camera } = useThree();
const composer = useRef<EffectComposer | null>(null);

useEffect(() => {
composer.current = new EffectComposer(gl);
composer.current.addPass(new RenderPass(scene, camera));
composer.current.addPass(new BloomPass(2, 5, 0.9));
composer.current.addPass(new OutputPass());
}, []);

useFrame(() => {
composer.current?.render();
}, 1);

return null;
};

export const DiamondScene = () => {
return (
<Canvas style={{ width: '100%', height: '100%' }} camera={{ position: [1, 3.5, 1], fov: 75 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[1, 3, 0]} intensity={1} />
<directionalLight position={[-1, -3, 0]} intensity={1} />
<pointLight position={[1, 5, 1]} intensity={0.5} />
<pointLight position={[-1, -5, -1]} intensity={0.5} />
<spotLight position={[5, 0, 5]} intensity={0.5} angle={Math.PI / 6} penumbra={1} />
<spotLight position={[-5, 0, -5]} intensity={0.5} angle={Math.PI / 6} penumbra={1} />

{/* Model should be wrapped in Suspense for loading */}
<Suspense fallback={null}>
<DiamondModel />
</Suspense>

<OrbitControls
autoRotate
autoRotateSpeed={2.0}
enableZoom={false}
enablePan={false}
minPolarAngle={Math.PI / 3}
maxPolarAngle={Math.PI / 3}
/>

<Effects />
</Canvas>
);
};
32 changes: 32 additions & 0 deletions app/shop/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Metadata } from 'next';
import { redirect } from 'next/navigation';

import { fetchUserProfile } from '../profile/server/fetch-user-profile';

export const metadata: Metadata = {
title: 'Gem shop',
alternates: {
canonical: '/shop',
},
openGraph: {
title: 'Taboo AI: Gem shop',
description: 'Buy more gems to play!',
url: 'https://taboo-ai.com/shop',
images: [
{
url: 'https://github.com/xmliszt/resources/blob/main/taboo-ai/images/v300/poster3.0(features).png?raw=true',
width: 800,
height: 600,
alt: 'Taboo AI: Ignite Learning Through Play 🚀🎮',
},
],
},
};

export default async function ShopLayout({ children }: { children: React.ReactNode }) {
// Permission: Must be logged in
const user = await fetchUserProfile();
if (!user) redirect('/sign-in');

return <>{children}</>;
}
45 changes: 45 additions & 0 deletions app/shop/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { cn } from '@/lib/utils';

import { DiamondScene } from './diamond-scene';
import { getPrices } from './server/get-prices';
import { ShopItemCarousel } from './shop-item-carousel';

const PRICE_IDS = [
'price_1PwjEAF1rEoFWlQgOGRo8lMd',
'price_1PxccyF1rEoFWlQgwIAkT8it',
'price_1PxccyF1rEoFWlQg1fvz4zyU',
];

export default async function Page() {
const prices = await getPrices(...PRICE_IDS);
prices.sort((a, b) => (a.unit_amount ?? 0) - (b.unit_amount ?? 0));

return (
<main className='relative flex flex-col items-center pt-6 md:pt-16 [&_*]:select-none'>
{/* Overlay gradient lining */}
<div
className={cn(
'pointer-events-none absolute z-10 -mt-6 h-full w-full md:-mt-16',
'animate-pulse',
'shadow-[inset_0_0_50px_10px_rgba(147,0,255,0.25)] dark:shadow-[inset_0_0_50px_10px_rgba(147,0,255,0.75)]'
)}
/>
<div className='h-auto w-full md:h-48 md:w-96'>
<DiamondScene />
</div>
<div className='flex w-full items-center justify-center'>
<ShopItemCarousel
products={prices.map((price, idx) => ({
id: idx,
name: price.metadata.name,
description: price.metadata.description,
tokens: parseInt(price.metadata.tokens_granted),
priceId: price.id,
price: price.unitDollar,
currency: price.currency,
}))}
/>
</div>
</main>
);
}
29 changes: 29 additions & 0 deletions app/shop/server/create-payment-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use server';

import { stripe } from '@/lib/stripe/server';

export async function createPaymentLink({
priceId,
redirectUrl,
}: {
priceId: string;
redirectUrl: string;
}) {
const paymentLink = await stripe.paymentLinks.create({
line_items: [
{
price: priceId,
quantity: 1,
},
],
allow_promotion_codes: true,
automatic_tax: {
enabled: true,
},
after_completion: {
type: 'redirect',
redirect: { url: `${redirectUrl}/{CHECKOUT_SESSION_ID}` },
},
});
return paymentLink;
}
Loading
Loading