sunpeak 0.2.6 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -17
- package/dist/chatgpt/chatgpt-simulator-types.d.ts +8 -0
- package/dist/chatgpt/chatgpt-simulator.d.ts +11 -0
- package/dist/chatgpt/conversation.d.ts +11 -0
- package/dist/chatgpt/index.d.ts +3 -0
- package/dist/chatgpt/mcp-provider.d.ts +25 -0
- package/dist/chatgpt/mock-openai.d.ts +61 -0
- package/dist/chatgpt/openai-provider.d.ts +19 -0
- package/dist/chatgpt/openai-types.d.ts +81 -0
- package/dist/chatgpt/simple-sidebar.d.ts +22 -0
- package/dist/chatgpt/theme-provider.d.ts +13 -0
- package/dist/hooks/index.d.ts +14 -0
- package/dist/hooks/use-display-mode.d.ts +2 -0
- package/dist/hooks/use-locale.d.ts +1 -0
- package/dist/hooks/use-max-height.d.ts +1 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-safe-area.d.ts +2 -0
- package/dist/hooks/use-theme.d.ts +2 -0
- package/dist/hooks/use-tool-input.d.ts +2 -0
- package/dist/hooks/use-tool-response-metadata.d.ts +2 -0
- package/dist/hooks/use-user-agent.d.ts +2 -0
- package/dist/hooks/use-view.d.ts +2 -0
- package/dist/hooks/use-widget-api.d.ts +8 -0
- package/dist/hooks/use-widget-global.d.ts +9 -0
- package/dist/hooks/use-widget-props.d.ts +1 -0
- package/dist/hooks/use-widget-state.d.ts +4 -0
- package/dist/index.cjs +3310 -666
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +5 -366
- package/dist/index.js +3325 -640
- package/dist/index.js.map +1 -1
- package/dist/lib/index.d.ts +2 -0
- package/dist/lib/media-queries.d.ts +3 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/mcp/index.cjs +799 -64
- package/dist/mcp/index.cjs.map +1 -1
- package/dist/mcp/index.d.ts +1 -12
- package/dist/mcp/index.js +786 -44
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/server.d.ts +10 -0
- package/dist/mcp/types.d.ts +74 -0
- package/dist/providers/index.d.ts +40 -0
- package/dist/providers/types.d.ts +71 -0
- package/dist/style.css +5279 -0
- package/dist/test/setup.d.ts +0 -0
- package/dist/types/index.d.ts +2 -0
- package/package.json +15 -20
- package/template/README.md +3 -6
- package/template/dev/main.tsx +0 -1
- package/template/mcp/server.ts +1 -1
- package/template/package.json +4 -14
- package/template/src/App.tsx +7 -8
- package/template/src/components/index.ts +2 -2
- package/template/src/components/openai-card.test.tsx +73 -0
- package/template/src/components/openai-card.tsx +126 -0
- package/template/src/components/openai-carousel.test.tsx +84 -0
- package/template/src/components/openai-carousel.tsx +178 -0
- package/template/src/styles/globals.css +5 -216
- package/template/vite.config.build.ts +61 -0
- package/template/vite.config.ts +0 -2
- package/dist/index.d.cts +0 -366
- package/dist/mcp/index.d.cts +0 -12
- package/dist/styles/chatgpt/index.css +0 -146
- package/dist/styles/globals.css +0 -219
- package/template/components.json +0 -21
- package/template/dev/styles.css +0 -6
- package/template/postcss.config.js +0 -5
- package/template/src/components/shadcn/button.tsx +0 -60
- package/template/src/components/shadcn/card.tsx +0 -76
- package/template/src/components/shadcn/carousel.tsx +0 -260
- package/template/src/components/shadcn/index.ts +0 -5
- package/template/src/components/shadcn/label.tsx +0 -24
- package/template/src/components/shadcn/select.tsx +0 -157
- package/template/src/components/sunpeak-card.test.tsx +0 -76
- package/template/src/components/sunpeak-card.tsx +0 -171
- package/template/src/components/sunpeak-carousel.test.tsx +0 -42
- package/template/src/components/sunpeak-carousel.tsx +0 -160
- package/template/src/styles/chatgpt.css +0 -146
- package/template/tsup.config.ts +0 -50
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sunpeak",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The ChatGPT Apps UI SDK. Build and test your ChatGPT App UI locally with
|
|
3
|
+
"version": "0.3.3",
|
|
4
|
+
"description": "The ChatGPT Apps UI SDK. Build and test your ChatGPT App UI locally with OpenAI apps-sdk-ui components.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
7
7
|
"module": "./dist/index.js",
|
|
@@ -13,18 +13,17 @@
|
|
|
13
13
|
"default": "./dist/index.js"
|
|
14
14
|
},
|
|
15
15
|
"require": {
|
|
16
|
-
"types": "./dist/index.d.
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
17
|
"default": "./dist/index.cjs"
|
|
18
18
|
}
|
|
19
19
|
},
|
|
20
|
+
"./style.css": "./dist/style.css",
|
|
20
21
|
"./mcp": {
|
|
21
22
|
"import": {
|
|
22
23
|
"types": "./dist/mcp/index.d.ts",
|
|
23
24
|
"default": "./dist/mcp/index.js"
|
|
24
25
|
}
|
|
25
26
|
},
|
|
26
|
-
"./styles/globals.css": "./dist/styles/globals.css",
|
|
27
|
-
"./styles/chatgpt": "./dist/styles/chatgpt/index.css",
|
|
28
27
|
"./package.json": "./package.json"
|
|
29
28
|
},
|
|
30
29
|
"bin": {
|
|
@@ -36,17 +35,21 @@
|
|
|
36
35
|
"template",
|
|
37
36
|
"README.md"
|
|
38
37
|
],
|
|
39
|
-
"sideEffects":
|
|
38
|
+
"sideEffects": [
|
|
39
|
+
"**/*.css"
|
|
40
|
+
],
|
|
40
41
|
"keywords": [
|
|
42
|
+
"openai-apps-sdk-ui",
|
|
41
43
|
"openai-app",
|
|
44
|
+
"chatgpt-apps-sdk",
|
|
42
45
|
"chatgpt-app",
|
|
46
|
+
"mcp-ui",
|
|
47
|
+
"mcp",
|
|
43
48
|
"chatgpt-simulator",
|
|
44
49
|
"testing",
|
|
45
50
|
"multi-platform",
|
|
46
51
|
"react",
|
|
47
|
-
"
|
|
48
|
-
"shadcn-ui",
|
|
49
|
-
"tailwindcss",
|
|
52
|
+
"tailwind",
|
|
50
53
|
"sunpeak"
|
|
51
54
|
],
|
|
52
55
|
"author": "Sunpeak AI",
|
|
@@ -57,17 +60,9 @@
|
|
|
57
60
|
},
|
|
58
61
|
"dependencies": {
|
|
59
62
|
"@modelcontextprotocol/sdk": "^0.5.0",
|
|
60
|
-
"@
|
|
61
|
-
"@radix-ui/react-label": "^2.1.8",
|
|
62
|
-
"@radix-ui/react-select": "^2.2.6",
|
|
63
|
-
"@radix-ui/react-separator": "^1.1.8",
|
|
64
|
-
"@radix-ui/react-slot": "^1.2.4",
|
|
65
|
-
"@radix-ui/react-tooltip": "^1.2.8",
|
|
66
|
-
"class-variance-authority": "^0.7.1",
|
|
63
|
+
"@openai/apps-sdk-ui": "^0.2.0",
|
|
67
64
|
"clsx": "^2.1.1",
|
|
68
|
-
"lucide-react": "^0.554.0",
|
|
69
65
|
"tailwind-merge": "^3.4.0",
|
|
70
|
-
"tw-animate-css": "^1.4.0",
|
|
71
66
|
"zod": "^3.23.8"
|
|
72
67
|
},
|
|
73
68
|
"devDependencies": {
|
|
@@ -91,10 +86,10 @@
|
|
|
91
86
|
"react-dom": "^18.3.1",
|
|
92
87
|
"tailwindcss": "^4.1.17",
|
|
93
88
|
"ts-node": "^10.9.2",
|
|
94
|
-
"tsup": "^8.3.5",
|
|
95
89
|
"tsx": "^4.20.6",
|
|
96
90
|
"typescript": "^5.6.3",
|
|
97
91
|
"vite": "^5.4.21",
|
|
92
|
+
"vite-plugin-dts": "^4.5.4",
|
|
98
93
|
"vitest": "^4.0.12"
|
|
99
94
|
},
|
|
100
95
|
"repository": {
|
|
@@ -106,7 +101,7 @@
|
|
|
106
101
|
},
|
|
107
102
|
"homepage": "https://sunpeak.ai/",
|
|
108
103
|
"scripts": {
|
|
109
|
-
"build": "
|
|
104
|
+
"build": "vite build",
|
|
110
105
|
"dev": "pnpm --filter my-sunpeak-app dev",
|
|
111
106
|
"lint": "eslint . --ext .ts,.tsx --fix",
|
|
112
107
|
"typecheck": "tsc --noEmit",
|
package/template/README.md
CHANGED
|
@@ -4,8 +4,6 @@ A ChatGPT App UI built with [sunpeak](https://github.com/Sunpeak-AI/sunpeak).
|
|
|
4
4
|
|
|
5
5
|
## Quickstart
|
|
6
6
|
|
|
7
|
-
Requirements: Node (20+), pnpm (10+)
|
|
8
|
-
|
|
9
7
|
```bash
|
|
10
8
|
pnpm dev
|
|
11
9
|
```
|
|
@@ -19,7 +17,7 @@ Edit [src/App.tsx](./src/App.tsx) to build your app UI.
|
|
|
19
17
|
```
|
|
20
18
|
src/
|
|
21
19
|
├── App.tsx # Your main app component
|
|
22
|
-
└── components/ # Your
|
|
20
|
+
└── components/ # Your React components
|
|
23
21
|
|
|
24
22
|
mcp/
|
|
25
23
|
└── server.ts # MCP server for testing in ChatGPT
|
|
@@ -57,9 +55,9 @@ pnpm mcp
|
|
|
57
55
|
ngrok http 6766
|
|
58
56
|
```
|
|
59
57
|
|
|
60
|
-
|
|
58
|
+
You can then connect to the tunnel forwarding URL at the `/mcp` path from ChatGPT **in developer mode** to see your UI in action: `User > Settings > Apps & Connectors > Create`
|
|
61
59
|
|
|
62
|
-
|
|
60
|
+
Once your app is connected, send `show app` to ChatGPT. Many changes require you to Refresh your app on the same settings modal.
|
|
63
61
|
|
|
64
62
|
## Build & Deploy
|
|
65
63
|
|
|
@@ -80,4 +78,3 @@ This creates optimized builds in the `dist/` directory:
|
|
|
80
78
|
- [ChatGPT Apps SDK Design Guidelines](https://developers.openai.com/apps-sdk/concepts/design-guidelines)
|
|
81
79
|
- [ChatGPT Apps SDK UI Documentation](https://developers.openai.com/apps-sdk/build/chatgpt-ui)
|
|
82
80
|
- [ChatGPT Apps SDK Examples](https://github.com/openai/openai-apps-sdk-examples)
|
|
83
|
-
- [shadcn/ui](https://ui.shadcn.com/)
|
package/template/dev/main.tsx
CHANGED
package/template/mcp/server.ts
CHANGED
|
@@ -58,7 +58,7 @@ const toolOutput = {
|
|
|
58
58
|
runMCPServer({
|
|
59
59
|
name: 'my-sunpeak-app',
|
|
60
60
|
version: '0.1.0',
|
|
61
|
-
distPath: path.resolve(__dirname, '../dist/chatgpt/index.
|
|
61
|
+
distPath: path.resolve(__dirname, '../dist/chatgpt/index.js'),
|
|
62
62
|
toolName: 'show-places',
|
|
63
63
|
toolDescription: 'Show popular places in Austin',
|
|
64
64
|
dummyData: toolOutput,
|
package/template/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"build": "
|
|
7
|
+
"build": "vite build --config vite.config.build.ts",
|
|
8
8
|
"dev": "vite",
|
|
9
9
|
"mcp": "tsx mcp/server.ts",
|
|
10
10
|
"lint": "eslint . --ext .ts,.tsx --fix",
|
|
@@ -12,25 +12,15 @@
|
|
|
12
12
|
"test": "vitest run"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@
|
|
16
|
-
"@radix-ui/react-label": "^2.1.8",
|
|
17
|
-
"@radix-ui/react-select": "^2.2.6",
|
|
18
|
-
"@radix-ui/react-separator": "^1.1.8",
|
|
19
|
-
"@radix-ui/react-slot": "^1.2.4",
|
|
20
|
-
"@radix-ui/react-tooltip": "^1.2.8",
|
|
21
|
-
"class-variance-authority": "^0.7.1",
|
|
15
|
+
"@openai/apps-sdk-ui": "^0.2.0",
|
|
22
16
|
"clsx": "^2.1.1",
|
|
23
17
|
"embla-carousel-react": "^8.6.0",
|
|
24
18
|
"embla-carousel-wheel-gestures": "^8.1.0",
|
|
25
|
-
"lucide-react": "^0.554.0",
|
|
26
19
|
"sunpeak": "workspace:*",
|
|
27
|
-
"tailwind-merge": "^3.4.0"
|
|
28
|
-
"tw-animate-css": "^1.4.0"
|
|
20
|
+
"tailwind-merge": "^3.4.0"
|
|
29
21
|
},
|
|
30
22
|
"devDependencies": {
|
|
31
|
-
"@tailwindcss/postcss": "^4.1.17",
|
|
32
23
|
"@tailwindcss/vite": "^4.1.17",
|
|
33
|
-
"postcss": "^8.4.49",
|
|
34
24
|
"@testing-library/jest-dom": "^6.9.1",
|
|
35
25
|
"@testing-library/react": "^16.3.0",
|
|
36
26
|
"@testing-library/user-event": "^14.6.1",
|
|
@@ -45,12 +35,12 @@
|
|
|
45
35
|
"eslint-plugin-react": "^7.37.5",
|
|
46
36
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
47
37
|
"jsdom": "^27.2.0",
|
|
38
|
+
"postcss": "^8.4.49",
|
|
48
39
|
"prettier": "^3.6.2",
|
|
49
40
|
"react": "^18.3.1",
|
|
50
41
|
"react-dom": "^18.3.1",
|
|
51
42
|
"tailwindcss": "^4.1.17",
|
|
52
43
|
"ts-node": "^10.9.2",
|
|
53
|
-
"tsup": "^8.3.5",
|
|
54
44
|
"tsx": "^4.20.6",
|
|
55
45
|
"typescript": "^5.6.3",
|
|
56
46
|
"vite": "^5.4.21",
|
package/template/src/App.tsx
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { useWidgetProps } from 'sunpeak';
|
|
2
|
-
import { SunpeakCarousel, SunpeakCard } from './components';
|
|
3
|
-
import 'tw-animate-css';
|
|
4
1
|
import '@/styles/globals.css';
|
|
5
|
-
|
|
2
|
+
|
|
3
|
+
import { useWidgetProps } from 'sunpeak';
|
|
4
|
+
import { OpenAICarousel, OpenAICard } from './components';
|
|
6
5
|
|
|
7
6
|
export interface Place {
|
|
8
7
|
id: string;
|
|
@@ -22,9 +21,9 @@ export function App() {
|
|
|
22
21
|
const data = useWidgetProps<AppData>(() => ({ places: [] }));
|
|
23
22
|
|
|
24
23
|
return (
|
|
25
|
-
<
|
|
24
|
+
<OpenAICarousel gap={16} showArrows={true} showEdgeGradients={true} cardWidth={220}>
|
|
26
25
|
{data.places.map((place) => (
|
|
27
|
-
<
|
|
26
|
+
<OpenAICard
|
|
28
27
|
key={place.id}
|
|
29
28
|
image={place.image}
|
|
30
29
|
imageAlt={place.name}
|
|
@@ -42,8 +41,8 @@ export function App() {
|
|
|
42
41
|
}}
|
|
43
42
|
>
|
|
44
43
|
{place.description}
|
|
45
|
-
</
|
|
44
|
+
</OpenAICard>
|
|
46
45
|
))}
|
|
47
|
-
</
|
|
46
|
+
</OpenAICarousel>
|
|
48
47
|
);
|
|
49
48
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from './
|
|
2
|
-
export * from './
|
|
1
|
+
export * from './openai-carousel';
|
|
2
|
+
export * from './openai-card';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { OpenAICard } from './openai-card';
|
|
4
|
+
|
|
5
|
+
describe('OpenAICard', () => {
|
|
6
|
+
it('renders correct variant classes', () => {
|
|
7
|
+
const { container, rerender } = render(
|
|
8
|
+
<OpenAICard variant="default" data-testid="card">
|
|
9
|
+
Content
|
|
10
|
+
</OpenAICard>
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const card = container.firstChild as HTMLElement;
|
|
14
|
+
expect(card.className).toContain('border border-subtle bg-surface');
|
|
15
|
+
|
|
16
|
+
rerender(
|
|
17
|
+
<OpenAICard variant="bordered" data-testid="card">
|
|
18
|
+
Content
|
|
19
|
+
</OpenAICard>
|
|
20
|
+
);
|
|
21
|
+
expect(card.className).toContain('border-2 border-default bg-surface');
|
|
22
|
+
|
|
23
|
+
rerender(
|
|
24
|
+
<OpenAICard variant="elevated" data-testid="card">
|
|
25
|
+
Content
|
|
26
|
+
</OpenAICard>
|
|
27
|
+
);
|
|
28
|
+
expect(card.className).toContain('shadow-lg');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('button clicks stop propagation and do not trigger card onClick', () => {
|
|
32
|
+
const cardOnClick = vi.fn();
|
|
33
|
+
const button1OnClick = vi.fn();
|
|
34
|
+
|
|
35
|
+
render(
|
|
36
|
+
<OpenAICard
|
|
37
|
+
onClick={cardOnClick}
|
|
38
|
+
button1={{ onClick: button1OnClick, children: 'Click Me' }}
|
|
39
|
+
>
|
|
40
|
+
Content
|
|
41
|
+
</OpenAICard>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const button = screen.getByText('Click Me');
|
|
45
|
+
fireEvent.click(button);
|
|
46
|
+
|
|
47
|
+
expect(button1OnClick).toHaveBeenCalledTimes(1);
|
|
48
|
+
expect(cardOnClick).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('calls button onClick handlers when buttons are clicked', () => {
|
|
52
|
+
const button1OnClick = vi.fn();
|
|
53
|
+
const button2OnClick = vi.fn();
|
|
54
|
+
|
|
55
|
+
render(
|
|
56
|
+
<OpenAICard
|
|
57
|
+
button1={{ onClick: button1OnClick, children: 'Button 1', isPrimary: true }}
|
|
58
|
+
button2={{ onClick: button2OnClick, children: 'Button 2' }}
|
|
59
|
+
>
|
|
60
|
+
Content
|
|
61
|
+
</OpenAICard>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const button1 = screen.getByText('Button 1');
|
|
65
|
+
const button2 = screen.getByText('Button 2');
|
|
66
|
+
|
|
67
|
+
fireEvent.click(button1);
|
|
68
|
+
expect(button1OnClick).toHaveBeenCalledTimes(1);
|
|
69
|
+
|
|
70
|
+
fireEvent.click(button2);
|
|
71
|
+
expect(button2OnClick).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { Button } from "@openai/apps-sdk-ui/components/Button"
|
|
3
|
+
import { cn } from "@/lib/index"
|
|
4
|
+
|
|
5
|
+
export interface OpenAIButtonProps {
|
|
6
|
+
isPrimary?: boolean
|
|
7
|
+
onClick: () => void
|
|
8
|
+
children: React.ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface OpenAICardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
12
|
+
children?: React.ReactNode
|
|
13
|
+
image?: string
|
|
14
|
+
imageAlt?: string
|
|
15
|
+
imageMaxWidth?: number
|
|
16
|
+
imageMaxHeight?: number
|
|
17
|
+
header?: React.ReactNode
|
|
18
|
+
metadata?: React.ReactNode
|
|
19
|
+
button1?: OpenAIButtonProps
|
|
20
|
+
button2?: OpenAIButtonProps
|
|
21
|
+
variant?: "default" | "bordered" | "elevated"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const OpenAICard = React.forwardRef<HTMLDivElement, OpenAICardProps>(
|
|
25
|
+
(
|
|
26
|
+
{
|
|
27
|
+
children,
|
|
28
|
+
image,
|
|
29
|
+
imageAlt,
|
|
30
|
+
imageMaxWidth = 400,
|
|
31
|
+
imageMaxHeight = 400,
|
|
32
|
+
header,
|
|
33
|
+
metadata,
|
|
34
|
+
button1,
|
|
35
|
+
button2,
|
|
36
|
+
variant = "default",
|
|
37
|
+
className,
|
|
38
|
+
onClick,
|
|
39
|
+
...props
|
|
40
|
+
},
|
|
41
|
+
ref
|
|
42
|
+
) => {
|
|
43
|
+
const variantClasses = {
|
|
44
|
+
default: "border border-subtle bg-surface",
|
|
45
|
+
bordered: "border-2 border-default bg-surface",
|
|
46
|
+
elevated: "border border-subtle bg-surface shadow-lg",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const handleCardClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
50
|
+
onClick?.(e)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const renderButton = (buttonProps: OpenAIButtonProps) => {
|
|
54
|
+
const { isPrimary = false, onClick: buttonOnClick, children } = buttonProps
|
|
55
|
+
|
|
56
|
+
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
57
|
+
e.stopPropagation()
|
|
58
|
+
buttonOnClick()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Button
|
|
63
|
+
color={isPrimary ? "primary" : "secondary"}
|
|
64
|
+
variant={isPrimary ? "solid" : "soft"}
|
|
65
|
+
onClick={handleClick}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</Button>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const hasButtons = button1 || button2
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
ref={ref}
|
|
77
|
+
className={cn(
|
|
78
|
+
"overflow-hidden rounded-2xl cursor-pointer select-none",
|
|
79
|
+
variantClasses[variant],
|
|
80
|
+
className
|
|
81
|
+
)}
|
|
82
|
+
onClick={handleCardClick}
|
|
83
|
+
{...props}
|
|
84
|
+
>
|
|
85
|
+
{image && (
|
|
86
|
+
<div className="w-full overflow-hidden">
|
|
87
|
+
<img
|
|
88
|
+
src={image}
|
|
89
|
+
alt={imageAlt}
|
|
90
|
+
loading="lazy"
|
|
91
|
+
className="w-full h-auto aspect-square object-cover"
|
|
92
|
+
style={{
|
|
93
|
+
maxWidth: `${imageMaxWidth}px`,
|
|
94
|
+
maxHeight: `${imageMaxHeight}px`,
|
|
95
|
+
}}
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
<div className="flex flex-col flex-1 p-4">
|
|
100
|
+
{header && (
|
|
101
|
+
<h2 className="font-medium text-base leading-tight overflow-hidden text-ellipsis whitespace-nowrap mb-2">
|
|
102
|
+
{header}
|
|
103
|
+
</h2>
|
|
104
|
+
)}
|
|
105
|
+
{metadata && (
|
|
106
|
+
<p className="text-secondary text-xs mb-1">
|
|
107
|
+
{metadata}
|
|
108
|
+
</p>
|
|
109
|
+
)}
|
|
110
|
+
{children && (
|
|
111
|
+
<div className="text-sm leading-normal line-clamp-2 mb-3">
|
|
112
|
+
{children}
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
{hasButtons && (
|
|
116
|
+
<div className="flex gap-2 flex-wrap mt-auto">
|
|
117
|
+
{button1 && renderButton(button1)}
|
|
118
|
+
{button2 && renderButton(button2)}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
OpenAICard.displayName = "OpenAICard"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { OpenAICarousel } from './openai-carousel';
|
|
4
|
+
|
|
5
|
+
const mockUseDisplayMode = vi.fn(() => 'inline');
|
|
6
|
+
|
|
7
|
+
// Mock sunpeak hooks
|
|
8
|
+
vi.mock('sunpeak', () => ({
|
|
9
|
+
useWidgetState: vi.fn(() => [{ currentIndex: 0 }, vi.fn()]),
|
|
10
|
+
useDisplayMode: () => mockUseDisplayMode(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Mock embla-carousel-react
|
|
14
|
+
vi.mock('embla-carousel-react', () => ({
|
|
15
|
+
default: vi.fn(() => [vi.fn(), null]),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Mock embla-carousel-wheel-gestures
|
|
19
|
+
vi.mock('embla-carousel-wheel-gestures', () => ({
|
|
20
|
+
WheelGesturesPlugin: vi.fn(() => ({})),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
describe('OpenAICarousel', () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
mockUseDisplayMode.mockReturnValue('inline');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('renders all children with correct card width', () => {
|
|
29
|
+
const { container } = render(
|
|
30
|
+
<OpenAICarousel cardWidth={300}>
|
|
31
|
+
<div>Card 1</div>
|
|
32
|
+
<div>Card 2</div>
|
|
33
|
+
<div>Card 3</div>
|
|
34
|
+
</OpenAICarousel>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const cardContainers = container.querySelectorAll('.flex-none');
|
|
38
|
+
expect(cardContainers).toHaveLength(3);
|
|
39
|
+
|
|
40
|
+
cardContainers.forEach((cardContainer) => {
|
|
41
|
+
const element = cardContainer as HTMLElement;
|
|
42
|
+
expect(element.style.minWidth).toBe('300px');
|
|
43
|
+
expect(element.style.maxWidth).toBe('300px');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('handles cardWidth object with inline/fullscreen modes', () => {
|
|
48
|
+
// Test inline mode
|
|
49
|
+
mockUseDisplayMode.mockReturnValue('inline');
|
|
50
|
+
const { container: inlineContainer } = render(
|
|
51
|
+
<OpenAICarousel cardWidth={{ inline: 250, fullscreen: 400 }}>
|
|
52
|
+
<div>Card 1</div>
|
|
53
|
+
</OpenAICarousel>
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
let cardContainer = inlineContainer.querySelector('.flex-none') as HTMLElement;
|
|
57
|
+
expect(cardContainer.style.minWidth).toBe('250px');
|
|
58
|
+
|
|
59
|
+
// Test fullscreen mode
|
|
60
|
+
mockUseDisplayMode.mockReturnValue('fullscreen');
|
|
61
|
+
const { container: fullscreenContainer } = render(
|
|
62
|
+
<OpenAICarousel cardWidth={{ inline: 250, fullscreen: 400 }}>
|
|
63
|
+
<div>Card 1</div>
|
|
64
|
+
</OpenAICarousel>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
cardContainer = fullscreenContainer.querySelector('.flex-none') as HTMLElement;
|
|
68
|
+
expect(cardContainer.style.minWidth).toBe('400px');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('applies custom gap between cards', () => {
|
|
72
|
+
const { container } = render(
|
|
73
|
+
<OpenAICarousel gap={24}>
|
|
74
|
+
<div>Card 1</div>
|
|
75
|
+
<div>Card 2</div>
|
|
76
|
+
</OpenAICarousel>
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const carouselTrack = container.querySelector('.flex.touch-pan-y') as HTMLElement;
|
|
80
|
+
expect(carouselTrack.style.gap).toBe('24px');
|
|
81
|
+
expect(carouselTrack.style.marginLeft).toBe('-24px');
|
|
82
|
+
expect(carouselTrack.style.paddingLeft).toBe('24px');
|
|
83
|
+
});
|
|
84
|
+
});
|