Refactor to use ai/rsc (#253)
This commit is contained in:
parent
69ca8fcc22
commit
e85ba803dd
66 changed files with 2799 additions and 740 deletions
30
components/stocks/event.tsx
Normal file
30
components/stocks/event.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { format, parseISO } from 'date-fns'
|
||||
|
||||
interface Event {
|
||||
date: string
|
||||
headline: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export function Events({ props: events }: { props: Event[] }) {
|
||||
return (
|
||||
<div className="-mt-2 flex flex-col gap-2">
|
||||
{events.map(event => (
|
||||
<div
|
||||
key={event.date}
|
||||
className="max-w-96 flex shrink-0 flex-col rounded-lg bg-zinc-800 p-4 gap-1"
|
||||
>
|
||||
<div className="text-sm text-zinc-400">
|
||||
{format(parseISO(event.date), 'dd LLL, yyyy')}
|
||||
</div>
|
||||
<div className="text-base font-bold text-zinc-200">
|
||||
{event.headline}
|
||||
</div>
|
||||
<div className="text-zinc-500">
|
||||
{event.description.slice(0, 70)}...
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
components/stocks/events-skeleton.tsx
Normal file
31
components/stocks/events-skeleton.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
const placeholderEvents = [
|
||||
{
|
||||
date: '2022-10-01',
|
||||
headline: 'NVIDIA releases new AI-powered graphics card',
|
||||
description:
|
||||
'NVIDIA unveils the latest graphics card infused with AI capabilities, revolutionizing gaming and rendering experiences.'
|
||||
}
|
||||
]
|
||||
|
||||
export const EventsSkeleton = ({ events = placeholderEvents }) => {
|
||||
return (
|
||||
<div className="-mt-2 flex flex-col gap-2 py-4">
|
||||
{placeholderEvents.map(event => (
|
||||
<div
|
||||
key={event.date}
|
||||
className="max-w-96 flex gap-1 shrink-0 flex-col rounded-lg bg-zinc-800 p-4"
|
||||
>
|
||||
<div className="w-fit rounded-md bg-zinc-700 text-sm text-transparent">
|
||||
{event.date}
|
||||
</div>
|
||||
<div className="w-fit rounded-md bg-zinc-700 text-transparent">
|
||||
{event.headline}
|
||||
</div>
|
||||
<div className="w-auto rounded-md bg-zinc-700 text-transparent">
|
||||
{event.description.slice(0, 70)}...
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
components/stocks/index.tsx
Normal file
38
components/stocks/index.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { StockSkeleton } from './stock-skeleton'
|
||||
import { StocksSkeleton } from './stocks-skeleton'
|
||||
import { EventsSkeleton } from './events-skeleton'
|
||||
|
||||
export { spinner } from './spinner'
|
||||
export { BotCard, BotMessage, SystemMessage } from './message'
|
||||
|
||||
const Stock = dynamic(() => import('./stock').then(mod => mod.Stock), {
|
||||
ssr: false,
|
||||
loading: () => <StockSkeleton />
|
||||
})
|
||||
|
||||
const Purchase = dynamic(
|
||||
() => import('./stock-purchase').then(mod => mod.Purchase),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="rounded-lg bg-zinc-900 px-4 py-5 text-center text-xs">
|
||||
Loading stock info...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const Stocks = dynamic(() => import('./stocks').then(mod => mod.Stocks), {
|
||||
ssr: false,
|
||||
loading: () => <StocksSkeleton />
|
||||
})
|
||||
|
||||
const Events = dynamic(() => import('./event').then(mod => mod.Events), {
|
||||
ssr: false,
|
||||
loading: () => <EventsSkeleton />
|
||||
})
|
||||
|
||||
export { Stock, Purchase, Stocks, Events }
|
||||
134
components/stocks/message.tsx
Normal file
134
components/stocks/message.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
'use client'
|
||||
|
||||
import { IconOpenAI, IconUser } from '@/components/ui/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { spinner } from './spinner'
|
||||
import { CodeBlock } from '../ui/codeblock'
|
||||
import { MemoizedReactMarkdown } from '../markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import { StreamableValue } from 'ai/rsc'
|
||||
import { useStreamableText } from '@/lib/hooks/use-streamable-text'
|
||||
|
||||
// Different types of message bubbles.
|
||||
|
||||
export function UserMessage({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="group relative flex items-start md:-ml-12">
|
||||
<div className="flex size-[25px] shrink-0 select-none items-center justify-center rounded-md border bg-background shadow-sm">
|
||||
<IconUser />
|
||||
</div>
|
||||
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BotMessage({
|
||||
content,
|
||||
className
|
||||
}: {
|
||||
content: string | StreamableValue<string>
|
||||
className?: string
|
||||
}) {
|
||||
const text = useStreamableText(content)
|
||||
|
||||
return (
|
||||
<div className={cn('group relative flex items-start md:-ml-12', className)}>
|
||||
<div className="flex size-[24px] shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow-sm">
|
||||
<IconOpenAI />
|
||||
</div>
|
||||
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
|
||||
<MemoizedReactMarkdown
|
||||
className="prose break-words dark:prose-invert prose-p:leading-relaxed prose-pre:p-0"
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
components={{
|
||||
p({ children }) {
|
||||
return <p className="mb-2 last:mb-0">{children}</p>
|
||||
},
|
||||
code({ node, inline, className, children, ...props }) {
|
||||
if (children.length) {
|
||||
if (children[0] == '▍') {
|
||||
return (
|
||||
<span className="mt-1 animate-pulse cursor-default">▍</span>
|
||||
)
|
||||
}
|
||||
|
||||
children[0] = (children[0] as string).replace('`▍`', '▍')
|
||||
}
|
||||
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
key={Math.random()}
|
||||
language={(match && match[1]) || ''}
|
||||
value={String(children).replace(/\n$/, '')}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</MemoizedReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BotCard({
|
||||
children,
|
||||
showAvatar = true
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
showAvatar?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative flex items-start md:-ml-12">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-[24px] shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow-sm',
|
||||
!showAvatar && 'invisible'
|
||||
)}
|
||||
>
|
||||
<IconOpenAI />
|
||||
</div>
|
||||
<div className="ml-4 flex-1 px-1">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SystemMessage({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'mt-2 flex items-center justify-center gap-2 text-xs text-gray-500'
|
||||
}
|
||||
>
|
||||
<div className={'max-w-[600px] flex-initial p-2'}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SpinnerMessage() {
|
||||
return (
|
||||
<div className="group relative flex items-start md:-ml-12">
|
||||
<div className="flex size-[24px] shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow-sm">
|
||||
<IconOpenAI />
|
||||
</div>
|
||||
<div className="ml-4 h-[24px] flex flex-row items-center flex-1 space-y-2 overflow-hidden px-1">
|
||||
{spinner}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
components/stocks/spinner.tsx
Normal file
16
components/stocks/spinner.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
'use client'
|
||||
|
||||
export const spinner = (
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-5 animate-spin stroke-zinc-400"
|
||||
>
|
||||
<path d="M12 3v3m6.366-.366-2.12 2.12M21 12h-3m.366 6.366-2.12-2.12M12 21v-3m-6.366.366 2.12-2.12M3 12h3m-.366-6.366 2.12 2.12"></path>
|
||||
</svg>
|
||||
)
|
||||
146
components/stocks/stock-purchase.tsx
Normal file
146
components/stocks/stock-purchase.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
'use client'
|
||||
|
||||
import { useId, useState } from 'react'
|
||||
import { useActions, useAIState, useUIState } from 'ai/rsc'
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
|
||||
import type { AI } from '@/lib/chat/actions'
|
||||
|
||||
interface Purchase {
|
||||
defaultAmount?: number
|
||||
name: string
|
||||
price: number
|
||||
status: 'requires_action' | 'completed' | 'expired'
|
||||
}
|
||||
|
||||
export function Purchase({
|
||||
props: { defaultAmount, name, price, status = 'expired' }
|
||||
}: {
|
||||
props: Purchase
|
||||
}) {
|
||||
const [value, setValue] = useState(defaultAmount || 100)
|
||||
const [purchasingUI, setPurchasingUI] = useState<null | React.ReactNode>(null)
|
||||
const [aiState, setAIState] = useAIState<typeof AI>()
|
||||
const [, setMessages] = useUIState<typeof AI>()
|
||||
const { confirmPurchase } = useActions()
|
||||
|
||||
// Unique identifier for this UI component.
|
||||
const id = useId()
|
||||
|
||||
// Whenever the slider changes, we need to update the local value state and the history
|
||||
// so LLM also knows what's going on.
|
||||
function onSliderChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const newValue = Number(e.target.value)
|
||||
setValue(newValue)
|
||||
|
||||
// Insert a hidden history info to the list.
|
||||
const message = {
|
||||
role: 'system' as const,
|
||||
content: `[User has changed to purchase ${newValue} shares of ${name}. Total cost: $${(
|
||||
newValue * price
|
||||
).toFixed(2)}]`,
|
||||
|
||||
// Identifier of this UI component, so we don't insert it many times.
|
||||
id
|
||||
}
|
||||
|
||||
// If last history state is already this info, update it. This is to avoid
|
||||
// adding every slider change to the history.
|
||||
if (aiState.messages[aiState.messages.length - 1]?.id === id) {
|
||||
setAIState({
|
||||
...aiState,
|
||||
messages: [...aiState.messages.slice(0, -1), message]
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// If it doesn't exist, append it to history.
|
||||
setAIState({ ...aiState, messages: [...aiState.messages, message] })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-zinc-950 p-4 text-green-400 mt-2">
|
||||
<div className="float-right inline-block rounded-full bg-white/10 px-2 py-1 text-xs">
|
||||
+1.23% ↑
|
||||
</div>
|
||||
<div className="text-lg text-zinc-300">{name}</div>
|
||||
<div className="text-3xl font-bold">${price}</div>
|
||||
{purchasingUI ? (
|
||||
<div className="mt-4 text-zinc-200">{purchasingUI}</div>
|
||||
) : status === 'requires_action' ? (
|
||||
<>
|
||||
<div className="relative mt-6 pb-6">
|
||||
<p>Shares to purchase</p>
|
||||
<input
|
||||
id="labels-range-input"
|
||||
type="range"
|
||||
value={value}
|
||||
onChange={onSliderChange}
|
||||
min="10"
|
||||
max="1000"
|
||||
className="h-1 w-full cursor-pointer appearance-none rounded-lg bg-zinc-600 accent-green-500 dark:bg-zinc-700"
|
||||
/>
|
||||
<span className="absolute bottom-1 start-0 text-xs text-zinc-400">
|
||||
10
|
||||
</span>
|
||||
<span className="absolute bottom-1 start-1/3 -translate-x-1/2 text-xs text-zinc-400 rtl:translate-x-1/2">
|
||||
100
|
||||
</span>
|
||||
<span className="absolute bottom-1 start-2/3 -translate-x-1/2 text-xs text-zinc-400 rtl:translate-x-1/2">
|
||||
500
|
||||
</span>
|
||||
<span className="absolute bottom-1 end-0 text-xs text-zinc-400">
|
||||
1000
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<p>Total cost</p>
|
||||
<div className="flex flex-wrap items-center text-xl font-bold sm:items-end sm:gap-2 sm:text-3xl">
|
||||
<div className="flex basis-1/3 flex-col tabular-nums sm:basis-auto sm:flex-row sm:items-center sm:gap-2">
|
||||
{value}
|
||||
<span className="mb-1 text-sm font-normal text-zinc-600 dark:text-zinc-400 sm:mb-0">
|
||||
shares
|
||||
</span>
|
||||
</div>
|
||||
<div className="basis-1/3 text-center sm:basis-auto">×</div>
|
||||
<span className="flex basis-1/3 flex-col tabular-nums sm:basis-auto sm:flex-row sm:items-center sm:gap-2">
|
||||
${price}
|
||||
<span className="mb-1 ml-1 text-sm font-normal text-zinc-600 dark:text-zinc-400 sm:mb-0">
|
||||
per share
|
||||
</span>
|
||||
</span>
|
||||
<div className="mt-2 basis-full border-t border-t-zinc-700 pt-2 text-center sm:mt-0 sm:basis-auto sm:border-0 sm:pt-0 sm:text-left">
|
||||
= <span>{formatNumber(value * price)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="mt-6 w-full rounded-lg bg-green-500 px-4 py-2 text-zinc-900 dark:bg-green-500"
|
||||
onClick={async () => {
|
||||
const response = await confirmPurchase(name, price, value)
|
||||
setPurchasingUI(response.purchasingUI)
|
||||
|
||||
// Insert a new system message to the UI.
|
||||
setMessages((currentMessages: any) => [
|
||||
...currentMessages,
|
||||
response.newMessage
|
||||
])
|
||||
}}
|
||||
>
|
||||
Purchase
|
||||
</button>
|
||||
</>
|
||||
) : status === 'completed' ? (
|
||||
<p className="mb-2 text-white">
|
||||
You have successfully purchased {value} ${name}. Total cost:{' '}
|
||||
{formatNumber(value * price)}
|
||||
</p>
|
||||
) : status === 'expired' ? (
|
||||
<p className="mb-2 text-white">Your checkout session has expired!</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
components/stocks/stock-skeleton.tsx
Normal file
22
components/stocks/stock-skeleton.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export const StockSkeleton = () => {
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-950 p-4 text-green-400">
|
||||
<div className="float-right inline-block w-fit rounded-full bg-zinc-700 px-2 py-1 text-xs text-transparent">
|
||||
xxxxxxx
|
||||
</div>
|
||||
<div className="mb-1 w-fit rounded-md bg-zinc-700 text-lg text-transparent">
|
||||
xxxx
|
||||
</div>
|
||||
<div className="w-fit rounded-md bg-zinc-700 text-3xl font-bold text-transparent">
|
||||
xxxx
|
||||
</div>
|
||||
<div className="text mt-1 w-fit rounded-md bg-zinc-700 text-xs text-transparent">
|
||||
xxxxxx xxx xx xxxx xx xxx
|
||||
</div>
|
||||
|
||||
<div className="relative -mx-4 cursor-col-resize">
|
||||
<div style={{ height: 146 }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
210
components/stocks/stock.tsx
Normal file
210
components/stocks/stock.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useId } from 'react'
|
||||
import { scaleLinear } from 'd3-scale'
|
||||
import { subMonths, format } from 'date-fns'
|
||||
import { useResizeObserver } from 'usehooks-ts'
|
||||
import { useAIState } from 'ai/rsc'
|
||||
|
||||
interface Stock {
|
||||
symbol: string
|
||||
price: number
|
||||
delta: number
|
||||
}
|
||||
|
||||
export function Stock({ props: { symbol, price, delta } }: { props: Stock }) {
|
||||
const [aiState, setAIState] = useAIState()
|
||||
const id = useId()
|
||||
|
||||
const [priceAtTime, setPriceAtTime] = useState({
|
||||
time: '00:00',
|
||||
value: price.toFixed(2),
|
||||
x: 0
|
||||
})
|
||||
|
||||
const [startHighlight, setStartHighlight] = useState(0)
|
||||
const [endHighlight, setEndHighlight] = useState(0)
|
||||
|
||||
const chartRef = useRef<HTMLDivElement>(null)
|
||||
const { width = 0 } = useResizeObserver({
|
||||
ref: chartRef,
|
||||
box: 'border-box'
|
||||
})
|
||||
|
||||
const xToDate = scaleLinear(
|
||||
[0, width],
|
||||
[subMonths(new Date(), 6), new Date()]
|
||||
)
|
||||
const xToValue = scaleLinear(
|
||||
[0, width],
|
||||
[price - price / 2, price + price / 2]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (startHighlight && endHighlight) {
|
||||
const message = {
|
||||
id,
|
||||
role: 'system' as const,
|
||||
content: `[User has highlighted dates between between ${format(
|
||||
xToDate(startHighlight),
|
||||
'd LLL'
|
||||
)} and ${format(xToDate(endHighlight), 'd LLL, yyyy')}`
|
||||
}
|
||||
|
||||
if (aiState.messages[aiState.messages.length - 1]?.id === id) {
|
||||
setAIState({
|
||||
...aiState,
|
||||
messages: [...aiState.messages.slice(0, -1), message]
|
||||
})
|
||||
} else {
|
||||
setAIState({
|
||||
...aiState,
|
||||
messages: [...aiState.messages, message]
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [startHighlight, endHighlight])
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-zinc-950 p-4 text-green-400">
|
||||
<div className="float-right inline-block rounded-full bg-white/10 px-2 py-1 text-xs">
|
||||
{`${delta > 0 ? '+' : ''}${((delta / price) * 100).toFixed(2)}% ${
|
||||
delta > 0 ? '↑' : '↓'
|
||||
}`}
|
||||
</div>
|
||||
<div className="text-lg text-zinc-300">{symbol}</div>
|
||||
<div className="text-3xl font-bold">${price}</div>
|
||||
<div className="text mt-1 text-xs text-zinc-500">
|
||||
Closed: Feb 27, 4:59 PM EST
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative -mx-4 cursor-col-resize"
|
||||
onPointerDown={event => {
|
||||
if (chartRef.current) {
|
||||
const { clientX } = event
|
||||
const { left } = chartRef.current.getBoundingClientRect()
|
||||
|
||||
setStartHighlight(clientX - left)
|
||||
setEndHighlight(0)
|
||||
|
||||
setPriceAtTime({
|
||||
time: format(xToDate(clientX), 'dd LLL yy'),
|
||||
value: xToValue(clientX).toFixed(2),
|
||||
x: clientX - left
|
||||
})
|
||||
}
|
||||
}}
|
||||
onPointerUp={event => {
|
||||
if (chartRef.current) {
|
||||
const { clientX } = event
|
||||
const { left } = chartRef.current.getBoundingClientRect()
|
||||
|
||||
setEndHighlight(clientX - left)
|
||||
}
|
||||
}}
|
||||
onPointerMove={event => {
|
||||
if (chartRef.current) {
|
||||
const { clientX } = event
|
||||
const { left } = chartRef.current.getBoundingClientRect()
|
||||
|
||||
setPriceAtTime({
|
||||
time: format(xToDate(clientX), 'dd LLL yy'),
|
||||
value: xToValue(clientX).toFixed(2),
|
||||
x: clientX - left
|
||||
})
|
||||
}
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
setPriceAtTime({
|
||||
time: '00:00',
|
||||
value: price.toFixed(2),
|
||||
x: 0
|
||||
})
|
||||
}}
|
||||
ref={chartRef}
|
||||
>
|
||||
{priceAtTime.x > 0 ? (
|
||||
<div
|
||||
className="pointer-events-none absolute z-10 flex w-fit select-none gap-2 rounded-md bg-zinc-800 p-2"
|
||||
style={{
|
||||
left: priceAtTime.x - 124 / 2,
|
||||
top: 30
|
||||
}}
|
||||
>
|
||||
<div className="text-xs tabular-nums">${priceAtTime.value}</div>
|
||||
<div className="text-xs tabular-nums text-zinc-400">
|
||||
{priceAtTime.time}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{startHighlight ? (
|
||||
<div
|
||||
className="pointer-events-none absolute h-32 w-5 select-none rounded-md border border-zinc-500 bg-zinc-500/20"
|
||||
style={{
|
||||
left: startHighlight,
|
||||
width: endHighlight
|
||||
? endHighlight - startHighlight
|
||||
: priceAtTime.x - startHighlight,
|
||||
bottom: 0
|
||||
}}
|
||||
></div>
|
||||
) : null}
|
||||
|
||||
<svg
|
||||
viewBox="0 0 250.0 168.0"
|
||||
height="150"
|
||||
width="100%"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="fill-id-tsuid_31"
|
||||
x1="0%"
|
||||
x2="0%"
|
||||
y1="0%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stopColor="#34a853" stopOpacity="0.38"></stop>
|
||||
<stop offset="13%" stopColor="#e6f4ea" stopOpacity="0"></stop>
|
||||
</linearGradient>
|
||||
<clipPath id="range-id-tsuid_31">
|
||||
<rect height="100%" width="0" x="0" y="0"></rect>
|
||||
</clipPath>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="chart-grad-_f1bJZYLUHqWpxc8Prs2meA_33"
|
||||
x1="0%"
|
||||
x2="0%"
|
||||
y1="0%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stopColor="#34a853" stopOpacity="0.38"></stop>
|
||||
<stop offset="13%" stopColor="#e6f4ea" stopOpacity="0"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<clipPath id="mask-_f1bJZYLUHqWpxc8Prs2meA_32">
|
||||
<rect height="218" width="250" x="0" y="-5"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path
|
||||
d="M 0 42.86 L 0.89 46.26 L 1.78 44.3 L 2.68 44.24 L 3.57 42 L 4.46 43.42 L 5.35 43.62 L 6.25 47 L 7.14 47.65 L 8.03 47.69 L 8.92 45.55 L 9.82 43.19 L 10.71 43.9 L 11.6 42.83 L 12.49 42.81 L 13.39 46.75 L 14.28 43.06 L 15.17 40.8 L 16.06 39.72 L 16.96 39.77 L 17.85 45.77 L 18.74 44.93 L 19.63 44.35 L 20.53 40.29 L 21.42 42.77 L 22.31 42.12 L 23.2 43.4 L 24.1 47.95 L 24.99 50.15 L 25.88 48.59 L 26.77 42.18 L 27.67 44.1 L 28.56 39.91 L 29.45 44.92 L 30.34 47.62 L 31.24 48.06 L 32.13 47.67 L 33.02 56.47 L 33.91 57.74 L 34.8 65.48 L 35.7 64.47 L 36.59 47.25 L 37.48 58.26 L 38.37 52.04 L 39.27 55.8 L 40.16 92.92 L 41.05 105.2 L 41.94 102 L 42.84 106.14 L 43.73 78.71 L 44.62 104.6 L 45.51 96.58 L 46.41 67.56 L 47.3 69.53 L 48.19 69.99 L 49.08 66.75 L 49.98 69.72 L 50.87 70.13 L 51.76 71.3 L 52.65 70.03 L 53.55 67.92 L 54.44 66.41 L 55.33 97.12 L 56.22 95.93 L 57.12 95.03 L 58.01 95.09 L 58.9 65.56 L 59.79 65.12 L 60.69 82.42 L 61.58 74.7 L 62.47 71.13 L 63.36 82.43 L 64.26 96.02 L 65.15 100.36 L 66.04 98.6 L 66.93 103.37 L 67.82 102.12 L 68.72 97.08 L 69.61 89.74 L 70.5 90.7 L 71.39 93.46 L 72.29 94.24 L 73.18 97.8 L 74.07 97.88 L 74.96 96.63 L 75.86 96.27 L 76.75 97.15 L 77.64 100.12 L 78.53 100.51 L 79.43 106.59 L 80.32 104.54 L 81.21 100.31 L 82.1 118.76 L 83 106.24 L 83.89 114.8 L 84.78 174.89 L 85.67 122.28 L 86.57 149.25 L 87.46 151.47 L 88.35 153.38 L 89.24 153.5 L 90.14 149.24 L 91.03 122.44 L 91.92 122.08 L 92.81 147.16 L 93.71 147.46 L 94.6 119.13 L 95.49 117.97 L 96.38 122.22 L 97.28 116.38 L 98.17 119.53 L 99.06 119.65 L 99.95 120.15 L 100.84 120.22 L 101.74 121.28 L 102.63 121.4 L 103.52 122.97 L 104.41 122.15 L 105.31 120.6 L 106.2 116.55 L 107.09 122.23 L 107.98 120.96 L 108.88 119.54 L 109.77 120.19 L 110.66 120.99 L 111.55 119.92 L 112.45 115.69 L 113.34 116.33 L 114.23 116.07 L 115.12 115.34 L 116.02 111.34 L 116.91 107.23 L 117.8 113.21 L 118.69 98.77 L 119.59 97.04 L 120.48 96.56 L 121.37 96.36 L 122.26 99.7 L 123.16 103.33 L 124.05 100.38 L 124.94 99.68 L 125.83 99.02 L 126.73 102.56 L 127.62 103.25 L 128.51 103.38 L 129.4 104.89 L 130.3 118.07 L 131.19 100.82 L 132.08 103.06 L 132.97 103.47 L 133.86 99.8 L 134.76 111.28 L 135.65 107.73 L 136.54 107.46 L 137.43 108.08 L 138.33 109.82 L 139.22 110.94 L 140.11 111.3 L 141 108.14 L 141.9 109.35 L 142.79 108.38 L 143.68 99.08 L 144.57 99.02 L 145.47 98.61 L 146.36 99.07 L 147.25 99.26 L 148.14 95.1 L 149.04 92.08 L 149.93 92.76 L 150.82 92.87 L 151.71 83.31 L 152.61 82.93 L 153.5 84.86 L 154.39 84.12 L 155.28 94.08 L 156.18 93.31 L 157.07 94.23 L 157.96 94.58 L 158.85 99.33 L 159.75 80 L 160.64 90.28 L 161.53 84.07 L 162.42 68.37 L 163.32 76.88 L 164.21 81.78 L 165.1 80.72 L 165.99 73.89 L 166.88 77.14 L 167.78 67.58 L 168.67 59.82 L 169.56 61.91 L 170.45 61.07 L 171.35 73.74 L 172.24 77.02 L 173.13 78.61 L 174.02 71.59 L 174.92 68.24 L 175.81 72.14 L 176.7 65.37 L 177.59 76.73 L 178.49 88.02 L 179.38 88.01 L 180.27 88.27 L 181.16 86.23 L 182.06 86.14 L 182.95 89.54 L 183.84 94.16 L 184.73 97.72 L 185.63 81.52 L 186.52 92.85 L 187.41 94.14 L 188.3 93.06 L 189.2 92.64 L 190.09 92.44 L 190.98 91.75 L 191.87 90.53 L 192.77 88.27 L 193.66 85.44 L 194.55 82.26 L 195.44 85.08 L 196.34 85.65 L 197.23 53.43 L 198.12 72.01 L 199.01 38.37 L 199.9 69.43 L 200.8 74.46 L 201.69 74.22 L 202.58 82.46 L 203.47 77.01 L 204.37 87.8 L 205.26 91.56 L 206.15 76.69 L 207.04 76.46 L 207.94 78.13 L 208.83 80.06 L 209.72 92.79 L 210.61 87.74 L 211.51 88.21 L 212.4 88.47 L 213.29 87.35 L 214.18 89.69 L 215.08 77.37 L 215.97 87.95 L 216.86 75.16 L 217.75 70.47 L 218.65 85.11 L 219.54 88.1 L 220.43 88.06 L 221.32 86.34 L 222.22 76.91 L 223.11 75.33 L 224 73.6 L 224.89 25.31 L 225.79 44.14 L 226.68 43.93 L 227.57 45.13 L 228.46 44.03 L 229.36 35.73 L 230.25 33.65 L 231.14 34.81 L 232.03 17.64 L 232.92 21.13 L 233.82 19.37 L 234.71 24.66 L 235.6 23.87 L 236.49 22.56 L 237.39 28.48 L 238.28 25.33 L 239.17 28.51 L 240.06 30.83 L 240.96 35.79 L 241.85 34.6 L 242.74 31.2 L 243.63 32.97 L 244.53 33.01 L 245.42 31.38 L 246.31 30.21 L 247.2 27.75 L 248.1 25.27 L 248.99 23 L 249.88 23 L 250 23 L 2000 0 L 2000 1000 L -1000 1000"
|
||||
clipPath="url(#range-id-tsuid_31)"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
stroke="none"
|
||||
strokeWidth={2}
|
||||
fill='url("#fill-id-tsuid_31")'
|
||||
></path>
|
||||
<path
|
||||
clipPath="url(#mask-_f1bJZYLUHqWpxc8Prs2meA_32)"
|
||||
d="M 0 42.86 L 0.89 46.26 L 1.78 44.3 L 2.68 44.24 L 3.57 42 L 4.46 43.42 L 5.35 43.62 L 6.25 47 L 7.14 47.65 L 8.03 47.69 L 8.92 45.55 L 9.82 43.19 L 10.71 43.9 L 11.6 42.83 L 12.49 42.81 L 13.39 46.75 L 14.28 43.06 L 15.17 40.8 L 16.06 39.72 L 16.96 39.77 L 17.85 45.77 L 18.74 44.93 L 19.63 44.35 L 20.53 40.29 L 21.42 42.77 L 22.31 42.12 L 23.2 43.4 L 24.1 47.95 L 24.99 50.15 L 25.88 48.59 L 26.77 42.18 L 27.67 44.1 L 28.56 39.91 L 29.45 44.92 L 30.34 47.62 L 31.24 48.06 L 32.13 47.67 L 33.02 56.47 L 33.91 57.74 L 34.8 65.48 L 35.7 64.47 L 36.59 47.25 L 37.48 58.26 L 38.37 52.04 L 39.27 55.8 L 40.16 92.92 L 41.05 105.2 L 41.94 102 L 42.84 106.14 L 43.73 78.71 L 44.62 104.6 L 45.51 96.58 L 46.41 67.56 L 47.3 69.53 L 48.19 69.99 L 49.08 66.75 L 49.98 69.72 L 50.87 70.13 L 51.76 71.3 L 52.65 70.03 L 53.55 67.92 L 54.44 66.41 L 55.33 97.12 L 56.22 95.93 L 57.12 95.03 L 58.01 95.09 L 58.9 65.56 L 59.79 65.12 L 60.69 82.42 L 61.58 74.7 L 62.47 71.13 L 63.36 82.43 L 64.26 96.02 L 65.15 100.36 L 66.04 98.6 L 66.93 103.37 L 67.82 102.12 L 68.72 97.08 L 69.61 89.74 L 70.5 90.7 L 71.39 93.46 L 72.29 94.24 L 73.18 97.8 L 74.07 97.88 L 74.96 96.63 L 75.86 96.27 L 76.75 97.15 L 77.64 100.12 L 78.53 100.51 L 79.43 106.59 L 80.32 104.54 L 81.21 100.31 L 82.1 118.76 L 83 106.24 L 83.89 114.8 L 84.78 174.89 L 85.67 122.28 L 86.57 149.25 L 87.46 151.47 L 88.35 153.38 L 89.24 153.5 L 90.14 149.24 L 91.03 122.44 L 91.92 122.08 L 92.81 147.16 L 93.71 147.46 L 94.6 119.13 L 95.49 117.97 L 96.38 122.22 L 97.28 116.38 L 98.17 119.53 L 99.06 119.65 L 99.95 120.15 L 100.84 120.22 L 101.74 121.28 L 102.63 121.4 L 103.52 122.97 L 104.41 122.15 L 105.31 120.6 L 106.2 116.55 L 107.09 122.23 L 107.98 120.96 L 108.88 119.54 L 109.77 120.19 L 110.66 120.99 L 111.55 119.92 L 112.45 115.69 L 113.34 116.33 L 114.23 116.07 L 115.12 115.34 L 116.02 111.34 L 116.91 107.23 L 117.8 113.21 L 118.69 98.77 L 119.59 97.04 L 120.48 96.56 L 121.37 96.36 L 122.26 99.7 L 123.16 103.33 L 124.05 100.38 L 124.94 99.68 L 125.83 99.02 L 126.73 102.56 L 127.62 103.25 L 128.51 103.38 L 129.4 104.89 L 130.3 118.07 L 131.19 100.82 L 132.08 103.06 L 132.97 103.47 L 133.86 99.8 L 134.76 111.28 L 135.65 107.73 L 136.54 107.46 L 137.43 108.08 L 138.33 109.82 L 139.22 110.94 L 140.11 111.3 L 141 108.14 L 141.9 109.35 L 142.79 108.38 L 143.68 99.08 L 144.57 99.02 L 145.47 98.61 L 146.36 99.07 L 147.25 99.26 L 148.14 95.1 L 149.04 92.08 L 149.93 92.76 L 150.82 92.87 L 151.71 83.31 L 152.61 82.93 L 153.5 84.86 L 154.39 84.12 L 155.28 94.08 L 156.18 93.31 L 157.07 94.23 L 157.96 94.58 L 158.85 99.33 L 159.75 80 L 160.64 90.28 L 161.53 84.07 L 162.42 68.37 L 163.32 76.88 L 164.21 81.78 L 165.1 80.72 L 165.99 73.89 L 166.88 77.14 L 167.78 67.58 L 168.67 59.82 L 169.56 61.91 L 170.45 61.07 L 171.35 73.74 L 172.24 77.02 L 173.13 78.61 L 174.02 71.59 L 174.92 68.24 L 175.81 72.14 L 176.7 65.37 L 177.59 76.73 L 178.49 88.02 L 179.38 88.01 L 180.27 88.27 L 181.16 86.23 L 182.06 86.14 L 182.95 89.54 L 183.84 94.16 L 184.73 97.72 L 185.63 81.52 L 186.52 92.85 L 187.41 94.14 L 188.3 93.06 L 189.2 92.64 L 190.09 92.44 L 190.98 91.75 L 191.87 90.53 L 192.77 88.27 L 193.66 85.44 L 194.55 82.26 L 195.44 85.08 L 196.34 85.65 L 197.23 53.43 L 198.12 72.01 L 199.01 38.37 L 199.9 69.43 L 200.8 74.46 L 201.69 74.22 L 202.58 82.46 L 203.47 77.01 L 204.37 87.8 L 205.26 91.56 L 206.15 76.69 L 207.04 76.46 L 207.94 78.13 L 208.83 80.06 L 209.72 92.79 L 210.61 87.74 L 211.51 88.21 L 212.4 88.47 L 213.29 87.35 L 214.18 89.69 L 215.08 77.37 L 215.97 87.95 L 216.86 75.16 L 217.75 70.47 L 218.65 85.11 L 219.54 88.1 L 220.43 88.06 L 221.32 86.34 L 222.22 76.91 L 223.11 75.33 L 224 73.6 L 224.89 25.31 L 225.79 44.14 L 226.68 43.93 L 227.57 45.13 L 228.46 44.03 L 229.36 35.73 L 230.25 33.65 L 231.14 34.81 L 232.03 17.64 L 232.92 21.13 L 233.82 19.37 L 234.71 24.66 L 235.6 23.87 L 236.49 22.56 L 237.39 28.48 L 238.28 25.33 L 239.17 28.51 L 240.06 30.83 L 240.96 35.79 L 241.85 34.6 L 242.74 31.2 L 243.63 32.97 L 244.53 33.01 L 245.42 31.38 L 246.31 30.21 L 247.2 27.75 L 248.1 25.27 L 248.99 23 L 249.88 23 L 250 23 L 2000 0 L 2000 1000 L -1000 1000"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
stroke="#34a853"
|
||||
style={{ fill: 'url(#chart-grad-_f1bJZYLUHqWpxc8Prs2meA_33)' }}
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
components/stocks/stocks-skeleton.tsx
Normal file
9
components/stocks/stocks-skeleton.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const StocksSkeleton = () => {
|
||||
return (
|
||||
<div className="mb-4 flex flex-col gap-2 overflow-y-scroll pb-4 text-sm sm:flex-row">
|
||||
<div className="flex h-[60px] w-full cursor-pointer flex-row gap-2 rounded-lg bg-zinc-900 p-2 text-left hover:bg-zinc-800 sm:w-[208px]"></div>
|
||||
<div className="flex h-[60px] w-full cursor-pointer flex-row gap-2 rounded-lg bg-zinc-900 p-2 text-left hover:bg-zinc-800 sm:w-[208px]"></div>
|
||||
<div className="flex h-[60px] w-full cursor-pointer flex-row gap-2 rounded-lg bg-zinc-900 p-2 text-left hover:bg-zinc-800 sm:w-[208px]"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
components/stocks/stocks.tsx
Normal file
59
components/stocks/stocks.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
'use client'
|
||||
|
||||
import { useActions, useUIState } from 'ai/rsc'
|
||||
|
||||
import type { AI } from '@/lib/chat/actions'
|
||||
|
||||
interface Stock {
|
||||
symbol: string
|
||||
price: number
|
||||
delta: number
|
||||
}
|
||||
|
||||
export function Stocks({ props: stocks }: { props: Stock[] }) {
|
||||
const [, setMessages] = useUIState<typeof AI>()
|
||||
const { submitUserMessage } = useActions()
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-col gap-2 overflow-y-scroll pb-4 text-sm sm:flex-row">
|
||||
{stocks.map(stock => (
|
||||
<button
|
||||
key={stock.symbol}
|
||||
className="flex cursor-pointer flex-row gap-2 rounded-lg bg-zinc-800 p-2 text-left hover:bg-zinc-700 sm:w-52"
|
||||
onClick={async () => {
|
||||
const response = await submitUserMessage(`View ${stock.symbol}`)
|
||||
setMessages(currentMessages => [...currentMessages, response])
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`text-xl ${
|
||||
stock.delta > 0 ? 'text-green-600' : 'text-red-600'
|
||||
} flex w-11 flex-row justify-center rounded-md bg-white/10 p-2`}
|
||||
>
|
||||
{stock.delta > 0 ? '↑' : '↓'}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div className="bold uppercase text-zinc-300">{stock.symbol}</div>
|
||||
<div className="text-base text-zinc-500">${stock.price}</div>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-col">
|
||||
<div
|
||||
className={`${
|
||||
stock.delta > 0 ? 'text-green-600' : 'text-red-600'
|
||||
} bold text-right uppercase`}
|
||||
>
|
||||
{` ${((stock.delta / stock.price) * 100).toFixed(2)}%`}
|
||||
</div>
|
||||
<div
|
||||
className={`${
|
||||
stock.delta > 0 ? 'text-green-700' : 'text-red-700'
|
||||
} text-right text-base`}
|
||||
>
|
||||
{stock.delta}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue