@instockng/storefront-ui 1.0.105 → 1.0.107
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/dist/components/AssistantDrawer.d.ts +24 -0
- package/dist/components/AssistantDrawer.d.ts.map +1 -0
- package/dist/components/Checkout.d.ts.map +1 -1
- package/dist/components/ProductAssistantChips.d.ts +13 -0
- package/dist/components/ProductAssistantChips.d.ts.map +1 -0
- package/dist/components/SearchAssistantTrigger.d.ts +12 -0
- package/dist/components/SearchAssistantTrigger.d.ts.map +1 -0
- package/dist/hooks/usePaystackPayment.d.ts +1 -0
- package/dist/hooks/usePaystackPayment.d.ts.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +127 -121
- package/dist/index10.mjs +3 -3
- package/dist/index100.mjs +8 -3
- package/dist/index101.mjs +75 -2
- package/dist/index102.mjs +3 -82
- package/dist/index103.mjs +2 -54
- package/dist/index104.mjs +82 -5
- package/dist/index105.mjs +53 -4
- package/dist/index106.mjs +5 -178
- package/dist/index107.mjs +5 -53
- package/dist/index108.mjs +178 -68
- package/dist/index109.mjs +50 -34
- package/dist/index11.mjs +5 -5
- package/dist/index110.mjs +69 -43
- package/dist/index111.mjs +2 -2
- package/dist/index112.mjs +35 -26
- package/dist/index113.mjs +42 -17
- package/dist/index114.mjs +2 -215
- package/dist/index115.mjs +26 -178
- package/dist/index116.mjs +10 -14
- package/dist/index117.mjs +209 -17
- package/dist/index118.mjs +173 -26
- package/dist/index119.mjs +17 -151
- package/dist/index12.mjs +4 -4
- package/dist/index120.mjs +13 -10
- package/dist/index121.mjs +24 -22
- package/dist/index122.mjs +148 -76
- package/dist/index123.mjs +13 -31
- package/dist/index124.mjs +24 -138
- package/dist/index125.mjs +78 -49
- package/dist/index126.mjs +32 -17
- package/dist/index127.mjs +139 -21
- package/dist/index128.mjs +51 -19
- package/dist/index129.mjs +16 -18
- package/dist/index13.mjs +3 -3
- package/dist/index130.mjs +18 -12
- package/dist/index131.mjs +15 -14
- package/dist/index132.mjs +17 -13
- package/dist/index133.mjs +14 -58
- package/dist/index134.mjs +15 -11
- package/dist/index135.mjs +14 -32
- package/dist/index136.mjs +57 -16
- package/dist/index137.mjs +11 -27
- package/dist/index138.mjs +31 -19
- package/dist/index139.mjs +17 -12
- package/dist/index14.mjs +1 -1
- package/dist/index140.mjs +27 -14
- package/dist/index141.mjs +20 -40
- package/dist/index142.mjs +11 -15
- package/dist/index143.mjs +17 -264
- package/dist/index144.mjs +40 -63
- package/dist/index145.mjs +22 -7
- package/dist/index146.mjs +268 -2
- package/dist/index147.mjs +70 -2
- package/dist/index148.mjs +7 -32
- package/dist/index149.mjs +2 -2
- package/dist/index15.mjs +1 -1
- package/dist/index150.mjs +2 -2
- package/dist/index151.mjs +33 -2
- package/dist/index152.mjs +2 -2
- package/dist/index153.mjs +21 -2
- package/dist/index154.mjs +54 -16
- package/dist/index155.mjs +28 -31
- package/dist/index156.mjs +6 -37
- package/dist/index157.mjs +50 -16
- package/dist/index158.mjs +6 -18
- package/dist/index159.mjs +12 -2
- package/dist/index16.mjs +6 -6
- package/dist/index160.mjs +7 -20
- package/dist/index161.mjs +28 -46
- package/dist/index162.mjs +2 -2
- package/dist/index163.mjs +69 -29
- package/dist/index164.mjs +164 -15
- package/dist/index165.mjs +2 -2
- package/dist/index166.mjs +2 -2
- package/dist/index167.mjs +2 -2
- package/dist/index168.mjs +18 -2
- package/dist/index169.mjs +26 -66
- package/dist/index17.mjs +4 -4
- package/dist/index170.mjs +38 -2
- package/dist/index171.mjs +13 -48
- package/dist/index172.mjs +18 -2
- package/dist/index173.mjs +2 -36
- package/dist/index174.mjs +16 -148
- package/dist/index175.mjs +46 -2
- package/dist/index176.mjs +2 -2
- package/dist/index177.mjs +26 -15
- package/dist/index178.mjs +18 -2
- package/dist/index179.mjs +2 -2
- package/dist/index18.mjs +5 -5
- package/dist/index180.mjs +2 -26
- package/dist/index181.mjs +2 -2
- package/dist/index182.mjs +2 -2
- package/dist/index183.mjs +67 -19
- package/dist/index184.mjs +2 -23
- package/dist/index185.mjs +53 -2
- package/dist/index186.mjs +2 -2
- package/dist/index187.mjs +36 -2
- package/dist/index188.mjs +146 -17
- package/dist/index189.mjs +2 -2
- package/dist/index19.mjs +3 -3
- package/dist/index190.mjs +2 -23
- package/dist/index191.mjs +19 -2
- package/dist/index192.mjs +2 -2
- package/dist/index193.mjs +2 -2
- package/dist/index194.mjs +18 -15
- package/dist/index196.mjs +2 -23
- package/dist/index197.mjs +24 -2
- package/dist/index198.mjs +23 -2
- package/dist/index199.mjs +2 -2
- package/dist/index2.mjs +2 -2
- package/dist/index20.mjs +91 -90
- package/dist/index200.mjs +2 -2
- package/dist/index201.mjs +2 -2
- package/dist/index202.mjs +23 -2
- package/dist/index203.mjs +2 -2
- package/dist/index204.mjs +23 -2
- package/dist/index205.mjs +2 -127
- package/dist/index206.mjs +2 -2
- package/dist/index207.mjs +2 -74
- package/dist/index208.mjs +20 -71
- package/dist/index209.mjs +2 -21
- package/dist/index21.mjs +256 -51
- package/dist/index210.mjs +21 -54
- package/dist/index211.mjs +2 -29
- package/dist/index212.mjs +2 -7
- package/dist/index213.mjs +2 -52
- package/dist/index214.mjs +2 -6
- package/dist/index215.mjs +2 -12
- package/dist/index216.mjs +2 -7
- package/dist/index217.mjs +2 -28
- package/dist/index218.mjs +2 -2
- package/dist/index219.mjs +125 -68
- package/dist/index22.mjs +45 -62
- package/dist/index220.mjs +2 -167
- package/dist/index221.mjs +74 -2
- package/dist/index222.mjs +74 -2
- package/dist/index223.mjs +2 -2
- package/dist/index224.mjs +13 -2
- package/dist/index225.mjs +7 -2
- package/dist/index226.mjs +12 -2
- package/dist/index227.mjs +4 -107
- package/dist/index228.mjs +33 -2
- package/dist/index229.mjs +31 -2
- package/dist/index23.mjs +22 -22
- package/dist/index230.mjs +28 -2
- package/dist/index231.mjs +61 -2
- package/dist/index232.mjs +30 -36
- package/dist/index233.mjs +11 -2
- package/dist/index234.mjs +3 -243
- package/dist/index235.mjs +4 -2
- package/dist/index236.mjs +2 -33
- package/dist/index237.mjs +2 -65
- package/dist/index238.mjs +2 -25
- package/dist/index239.mjs +2 -2
- package/dist/index24.mjs +55 -104
- package/dist/index241.mjs +2 -2
- package/dist/index242.mjs +2 -2
- package/dist/index243.mjs +2 -2
- package/dist/index244.mjs +108 -2
- package/dist/index246.mjs +2 -2
- package/dist/index247.mjs +37 -2
- package/dist/index248.mjs +2 -2
- package/dist/index249.mjs +2 -4
- package/dist/index25.mjs +62 -42
- package/dist/index250.mjs +244 -2
- package/dist/index252.mjs +32 -30
- package/dist/index253.mjs +64 -10
- package/dist/index254.mjs +24 -3
- package/dist/index255.mjs +2 -4
- package/dist/index256.mjs +2 -13
- package/dist/index257.mjs +2 -7
- package/dist/index258.mjs +2 -12
- package/dist/index259.mjs +2 -5
- package/dist/index26.mjs +22 -40
- package/dist/index260.mjs +2 -33
- package/dist/index261.mjs +2 -31
- package/dist/index262.mjs +2 -28
- package/dist/index263.mjs +4 -61
- package/dist/index264.mjs +2 -2
- package/dist/index265.mjs +2 -2
- package/dist/index266.mjs +2 -18
- package/dist/index267.mjs +2 -47
- package/dist/index268.mjs +2 -2
- package/dist/index269.mjs +18 -2
- package/dist/index27.mjs +107 -87
- package/dist/index270.mjs +47 -2
- package/dist/index271.mjs +2 -2
- package/dist/index272.mjs +2 -91
- package/dist/index273.mjs +2 -2
- package/dist/index274.mjs +2 -3
- package/dist/index275.mjs +91 -2
- package/dist/index276.mjs +2 -2
- package/dist/index277.mjs +3 -17
- package/dist/index278.mjs +2 -13
- package/dist/index279.mjs +2 -6
- package/dist/index28.mjs +42 -32
- package/dist/index280.mjs +17 -30
- package/dist/index281.mjs +13 -2
- package/dist/index282.mjs +6 -2
- package/dist/index283.mjs +30 -2
- package/dist/index284.mjs +5 -0
- package/dist/index285.mjs +5 -0
- package/dist/index286.mjs +5 -0
- package/dist/index29.mjs +42 -9
- package/dist/index3.mjs +4 -4
- package/dist/index30.mjs +84 -17
- package/dist/index31.mjs +29 -35
- package/dist/index32.mjs +8 -39
- package/dist/index33.mjs +21 -125
- package/dist/index34.mjs +35 -46
- package/dist/index35.mjs +38 -9
- package/dist/index36.mjs +121 -6
- package/dist/index37.mjs +49 -123
- package/dist/index38.mjs +11 -28
- package/dist/index39.mjs +11 -91
- package/dist/index4.mjs +1 -1
- package/dist/index40.mjs +121 -123
- package/dist/index41.mjs +28 -11
- package/dist/index42.mjs +91 -35
- package/dist/index43.mjs +116 -37
- package/dist/index44.mjs +9 -9
- package/dist/index45.mjs +33 -121
- package/dist/index46.mjs +42 -385
- package/dist/index47.mjs +10 -24
- package/dist/index48.mjs +122 -31
- package/dist/index49.mjs +388 -27
- package/dist/index5.mjs +1 -1
- package/dist/index50.mjs +24 -6
- package/dist/index51.mjs +30 -1431
- package/dist/index52.mjs +26 -68
- package/dist/index53.mjs +7 -2
- package/dist/index54.mjs +1425 -52
- package/dist/index55.mjs +69 -50
- package/dist/index56.mjs +2 -33
- package/dist/index57.mjs +59 -14
- package/dist/index58.mjs +47 -2259
- package/dist/index59.mjs +33 -36
- package/dist/index6.mjs +1 -1
- package/dist/index60.mjs +14 -43
- package/dist/index61.mjs +2256 -96
- package/dist/index62.mjs +36 -81
- package/dist/index63.mjs +43 -18
- package/dist/index64.mjs +102 -128
- package/dist/index65.mjs +45 -89
- package/dist/index66.mjs +15 -75
- package/dist/index67.mjs +84 -130
- package/dist/index68.mjs +84 -66
- package/dist/index69.mjs +69 -26
- package/dist/index7.mjs +6 -6
- package/dist/index70.mjs +138 -58
- package/dist/index71.mjs +82 -56
- package/dist/index72.mjs +28 -55
- package/dist/index73.mjs +79 -46
- package/dist/index74.mjs +58 -112
- package/dist/index75.mjs +47 -54
- package/dist/index76.mjs +60 -22
- package/dist/index77.mjs +135 -2
- package/dist/index78.mjs +66 -149
- package/dist/index79.mjs +21 -20
- package/dist/index8.mjs +6 -6
- package/dist/index80.mjs +2 -75
- package/dist/index81.mjs +153 -15
- package/dist/index82.mjs +21 -61
- package/dist/index83.mjs +74 -4
- package/dist/index84.mjs +15 -2
- package/dist/index85.mjs +62 -5
- package/dist/index86.mjs +4 -1133
- package/dist/index87.mjs +2 -20
- package/dist/index88.mjs +5 -54
- package/dist/index89.mjs +1126 -25
- package/dist/index9.mjs +4 -4
- package/dist/index90.mjs +20 -2
- package/dist/index91.mjs +55 -2
- package/dist/index92.mjs +29 -231
- package/dist/index93.mjs +2 -6
- package/dist/index94.mjs +228 -127
- package/dist/index95.mjs +4 -66
- package/dist/index96.mjs +124 -77
- package/dist/index97.mjs +65 -26
- package/dist/index98.mjs +84 -6
- package/dist/index99.mjs +26 -72
- package/dist/styles.css +1 -1
- package/package.json +2 -1
- package/src/components/AssistantDrawer.stories.tsx +140 -0
- package/src/components/AssistantDrawer.tsx +430 -0
- package/src/components/Checkout.tsx +1 -0
- package/src/components/ProductAssistantChips.stories.tsx +42 -0
- package/src/components/ProductAssistantChips.tsx +64 -0
- package/src/components/SearchAssistantTrigger.stories.tsx +35 -0
- package/src/components/SearchAssistantTrigger.tsx +41 -0
- package/src/hooks/usePaystackPayment.ts +1 -0
- package/src/index.ts +10 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { useState, useCallback } from 'react';
|
|
3
|
+
import { AssistantDrawer } from './AssistantDrawer';
|
|
4
|
+
import { ProductAssistantChips } from './ProductAssistantChips';
|
|
5
|
+
import { SearchAssistantTrigger } from './SearchAssistantTrigger';
|
|
6
|
+
|
|
7
|
+
const meta = {
|
|
8
|
+
title: 'Components/AssistantDrawer',
|
|
9
|
+
component: AssistantDrawer,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: 'fullscreen',
|
|
12
|
+
},
|
|
13
|
+
decorators: [
|
|
14
|
+
// Override the default p-8 decorator — drawer needs full viewport
|
|
15
|
+
(Story) => (
|
|
16
|
+
<div style={{ position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden' }}>
|
|
17
|
+
<Story />
|
|
18
|
+
</div>
|
|
19
|
+
),
|
|
20
|
+
],
|
|
21
|
+
tags: ['autodocs'],
|
|
22
|
+
} satisfies Meta<typeof AssistantDrawer>;
|
|
23
|
+
|
|
24
|
+
export default meta;
|
|
25
|
+
type Story = StoryObj<typeof meta>;
|
|
26
|
+
|
|
27
|
+
const mockFaqs = [
|
|
28
|
+
{ question: 'Are these leggings stretchy?', answer: 'Yes, made with 4-way stretch fabric.' },
|
|
29
|
+
{ question: 'Do they have pockets?', answer: 'Yes, two side pockets.' },
|
|
30
|
+
{ question: 'Can they be worn during workouts?', answer: 'Designed for high-intensity workouts.' },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function ProductChipsDemo() {
|
|
34
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
35
|
+
const [question, setQuestion] = useState('');
|
|
36
|
+
|
|
37
|
+
const handleAsk = useCallback((q: string) => {
|
|
38
|
+
setQuestion(q);
|
|
39
|
+
setIsOpen(true);
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
const handleClose = useCallback(() => {
|
|
43
|
+
setIsOpen(false);
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="p-8">
|
|
48
|
+
<h3 className="text-lg font-semibold mb-4">Product Page</h3>
|
|
49
|
+
<div className="max-w-md">
|
|
50
|
+
<div className="aspect-[4/3] bg-gray-100 rounded-lg mb-4 flex items-center justify-center text-gray-400">
|
|
51
|
+
Product Image
|
|
52
|
+
</div>
|
|
53
|
+
<ProductAssistantChips faqs={mockFaqs} onAsk={handleAsk} />
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<AssistantDrawer
|
|
57
|
+
isOpen={isOpen}
|
|
58
|
+
onClose={handleClose}
|
|
59
|
+
apiUrl="https://oms-api.instock.ng"
|
|
60
|
+
brandSlug="demo-brand"
|
|
61
|
+
productSlug="maternity-leggings"
|
|
62
|
+
initialQuestion={question}
|
|
63
|
+
assistantName="AI Assistant"
|
|
64
|
+
onProductClick={(slug) => console.log('Navigate to:', slug)}
|
|
65
|
+
disablePortal
|
|
66
|
+
/>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function SearchTriggerDemo() {
|
|
72
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
73
|
+
const [question, setQuestion] = useState('');
|
|
74
|
+
|
|
75
|
+
const handleAsk = useCallback((q: string) => {
|
|
76
|
+
setQuestion(q);
|
|
77
|
+
setIsOpen(true);
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
const handleClose = useCallback(() => {
|
|
81
|
+
setIsOpen(false);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="p-8">
|
|
86
|
+
<h3 className="text-lg font-semibold mb-4">Search Results</h3>
|
|
87
|
+
<div className="max-w-md space-y-4">
|
|
88
|
+
<p className="text-gray-500 text-sm">No results found for "toys for babies"</p>
|
|
89
|
+
<SearchAssistantTrigger
|
|
90
|
+
searchQuery="toys for babies"
|
|
91
|
+
onAsk={handleAsk}
|
|
92
|
+
label='Ask AI about "toys for babies"'
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<AssistantDrawer
|
|
97
|
+
isOpen={isOpen}
|
|
98
|
+
onClose={handleClose}
|
|
99
|
+
apiUrl="https://oms-api.instock.ng"
|
|
100
|
+
brandSlug="demo-brand"
|
|
101
|
+
initialQuestion={question}
|
|
102
|
+
assistantName="AI Assistant"
|
|
103
|
+
onProductClick={(slug) => console.log('Navigate to:', slug)}
|
|
104
|
+
disablePortal
|
|
105
|
+
/>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Interactive demo: FAQ chips → click → drawer opens with streaming answer
|
|
112
|
+
*/
|
|
113
|
+
export const WithProductChips: Story = {
|
|
114
|
+
render: () => <ProductChipsDemo />,
|
|
115
|
+
args: {} as any,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Interactive demo: search "Ask AI" button → drawer opens with streaming answer
|
|
120
|
+
*/
|
|
121
|
+
export const WithSearchTrigger: Story = {
|
|
122
|
+
render: () => <SearchTriggerDemo />,
|
|
123
|
+
args: {} as any,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Drawer opened directly
|
|
128
|
+
*/
|
|
129
|
+
export const OpenDrawer: Story = {
|
|
130
|
+
args: {
|
|
131
|
+
isOpen: true,
|
|
132
|
+
onClose: () => console.log('Close'),
|
|
133
|
+
apiUrl: 'https://oms-api.instock.ng',
|
|
134
|
+
brandSlug: 'demo-brand',
|
|
135
|
+
productSlug: 'maternity-leggings',
|
|
136
|
+
assistantName: 'AI Assistant',
|
|
137
|
+
onProductClick: (slug: string) => console.log('Navigate to:', slug),
|
|
138
|
+
disablePortal: true,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AssistantDrawer Component
|
|
5
|
+
*
|
|
6
|
+
* Chat drawer for the AI shopping assistant.
|
|
7
|
+
* - Mobile: bottom sheet
|
|
8
|
+
* - Desktop: side drawer from the right
|
|
9
|
+
*
|
|
10
|
+
* Streams responses via SSE, renders ProductCards for recommendations,
|
|
11
|
+
* shows follow-up suggestion chips.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
15
|
+
import { createPortal } from 'react-dom';
|
|
16
|
+
import { X, Send, Sparkles, Loader2 } from 'lucide-react';
|
|
17
|
+
import { cn, formatCurrency } from '../lib/utils';
|
|
18
|
+
import { useHideBodyOverflow } from '../hooks/useHideBodyOverflow';
|
|
19
|
+
|
|
20
|
+
type ChatMessage = {
|
|
21
|
+
role: 'user' | 'assistant';
|
|
22
|
+
content: string;
|
|
23
|
+
products?: RecommendedProduct[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type RecommendedProduct = {
|
|
27
|
+
slug: string;
|
|
28
|
+
name: string;
|
|
29
|
+
thumbnailUrl: string | null;
|
|
30
|
+
price: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export interface AssistantDrawerProps {
|
|
34
|
+
/** Controls drawer visibility */
|
|
35
|
+
isOpen: boolean;
|
|
36
|
+
/** Callback when drawer should close */
|
|
37
|
+
onClose: () => void;
|
|
38
|
+
/** API base URL */
|
|
39
|
+
apiUrl: string;
|
|
40
|
+
/** Brand slug for context */
|
|
41
|
+
brandSlug: string;
|
|
42
|
+
/** Product slug if opened from a product page */
|
|
43
|
+
productSlug?: string;
|
|
44
|
+
/** Initial question to send immediately when opened */
|
|
45
|
+
initialQuestion?: string;
|
|
46
|
+
/** Assistant display name */
|
|
47
|
+
assistantName?: string;
|
|
48
|
+
/** Callback when a product card is clicked */
|
|
49
|
+
onProductClick?: (slug: string) => void;
|
|
50
|
+
/** Custom class name */
|
|
51
|
+
className?: string;
|
|
52
|
+
/** Render inline instead of using a portal (useful for Storybook) */
|
|
53
|
+
disablePortal?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function AssistantDrawer({
|
|
57
|
+
isOpen,
|
|
58
|
+
onClose,
|
|
59
|
+
apiUrl,
|
|
60
|
+
brandSlug,
|
|
61
|
+
productSlug,
|
|
62
|
+
initialQuestion,
|
|
63
|
+
assistantName = 'AI Assistant',
|
|
64
|
+
onProductClick,
|
|
65
|
+
className,
|
|
66
|
+
disablePortal = false,
|
|
67
|
+
}: AssistantDrawerProps) {
|
|
68
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
69
|
+
const [input, setInput] = useState('');
|
|
70
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
71
|
+
const [error, setError] = useState<string | null>(null);
|
|
72
|
+
const [isAnimating, setIsAnimating] = useState(false);
|
|
73
|
+
const [shouldRender, setShouldRender] = useState(false);
|
|
74
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
75
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
76
|
+
const initialQuestionSent = useRef(false);
|
|
77
|
+
const messagesRef = useRef<ChatMessage[]>([]);
|
|
78
|
+
messagesRef.current = messages;
|
|
79
|
+
|
|
80
|
+
useHideBodyOverflow(isOpen);
|
|
81
|
+
|
|
82
|
+
// --- Callbacks (defined before effects that use them) ---
|
|
83
|
+
|
|
84
|
+
const handleSSEEvent = useCallback((event: string, data: string) => {
|
|
85
|
+
if (event === 'token') {
|
|
86
|
+
setMessages((prev) => {
|
|
87
|
+
const updated = [...prev];
|
|
88
|
+
const last = updated[updated.length - 1];
|
|
89
|
+
if (last?.role === 'assistant') {
|
|
90
|
+
updated[updated.length - 1] = { ...last, content: last.content + data };
|
|
91
|
+
}
|
|
92
|
+
return updated;
|
|
93
|
+
});
|
|
94
|
+
} else if (event === 'done') {
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(data);
|
|
97
|
+
setMessages((prev) => {
|
|
98
|
+
const updated = [...prev];
|
|
99
|
+
const last = updated[updated.length - 1];
|
|
100
|
+
if (last?.role === 'assistant') {
|
|
101
|
+
updated[updated.length - 1] = {
|
|
102
|
+
...last,
|
|
103
|
+
content: parsed.answer || last.content,
|
|
104
|
+
products: parsed.products?.length > 0 ? parsed.products : undefined,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return updated;
|
|
108
|
+
});
|
|
109
|
+
} catch {
|
|
110
|
+
// Ignore parse errors
|
|
111
|
+
}
|
|
112
|
+
} else if (event === 'error') {
|
|
113
|
+
try {
|
|
114
|
+
const parsed = JSON.parse(data);
|
|
115
|
+
setError(parsed.message || 'An error occurred');
|
|
116
|
+
} catch {
|
|
117
|
+
setError('An error occurred');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
const sendMessageRef = useRef<(question: string) => Promise<void>>();
|
|
123
|
+
const sendMessage = useCallback(
|
|
124
|
+
async (question: string) => {
|
|
125
|
+
if (!question.trim()) return;
|
|
126
|
+
|
|
127
|
+
setError(null);
|
|
128
|
+
const userMessage: ChatMessage = { role: 'user', content: question };
|
|
129
|
+
const history = messagesRef.current.map((m) => ({
|
|
130
|
+
role: m.role,
|
|
131
|
+
content: m.content,
|
|
132
|
+
}));
|
|
133
|
+
|
|
134
|
+
setMessages((prev) => [...prev, userMessage]);
|
|
135
|
+
setInput('');
|
|
136
|
+
setIsStreaming(true);
|
|
137
|
+
|
|
138
|
+
// Add placeholder assistant message
|
|
139
|
+
setMessages((prev) => [...prev, { role: 'assistant', content: '' }]);
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const response = await fetch(`${apiUrl}/v1/assistant/ask`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'Content-Type': 'application/json' },
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
question,
|
|
147
|
+
brandSlug,
|
|
148
|
+
productSlug,
|
|
149
|
+
history,
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
const err = await response.json().catch(() => ({}));
|
|
155
|
+
throw new Error(
|
|
156
|
+
(err as any)?.error?.message || `Request failed (${response.status})`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!response.body) throw new Error('No response body');
|
|
161
|
+
|
|
162
|
+
const reader = response.body.getReader();
|
|
163
|
+
const decoder = new TextDecoder();
|
|
164
|
+
let buffer = '';
|
|
165
|
+
let currentEvent = '';
|
|
166
|
+
|
|
167
|
+
while (true) {
|
|
168
|
+
const { done, value } = await reader.read();
|
|
169
|
+
if (done) break;
|
|
170
|
+
|
|
171
|
+
buffer += decoder.decode(value, { stream: true });
|
|
172
|
+
const lines = buffer.split('\n');
|
|
173
|
+
buffer = lines.pop() || '';
|
|
174
|
+
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
if (line.trim() === '') {
|
|
177
|
+
currentEvent = '';
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (line.trimStart().startsWith('event:')) {
|
|
181
|
+
currentEvent = line.trimStart().slice(6).trim();
|
|
182
|
+
} else if (line.trimStart().startsWith('data:')) {
|
|
183
|
+
// Per SSE spec: strip only the single leading space after "data:"
|
|
184
|
+
const afterPrefix = line.trimStart().slice(5);
|
|
185
|
+
const data = afterPrefix.startsWith(' ') ? afterPrefix.slice(1) : afterPrefix;
|
|
186
|
+
handleSSEEvent(currentEvent || 'token', data);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} catch (err: any) {
|
|
191
|
+
setError(err.message || 'Something went wrong');
|
|
192
|
+
// Remove the empty assistant message
|
|
193
|
+
setMessages((prev) => {
|
|
194
|
+
const last = prev[prev.length - 1];
|
|
195
|
+
if (last?.role === 'assistant' && !last.content) {
|
|
196
|
+
return prev.slice(0, -1);
|
|
197
|
+
}
|
|
198
|
+
return prev;
|
|
199
|
+
});
|
|
200
|
+
} finally {
|
|
201
|
+
setIsStreaming(false);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
[apiUrl, brandSlug, productSlug, handleSSEEvent]
|
|
205
|
+
);
|
|
206
|
+
sendMessageRef.current = sendMessage;
|
|
207
|
+
|
|
208
|
+
// --- Effects ---
|
|
209
|
+
|
|
210
|
+
// Animation handling
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (isOpen) {
|
|
213
|
+
setShouldRender(true);
|
|
214
|
+
requestAnimationFrame(() => {
|
|
215
|
+
requestAnimationFrame(() => {
|
|
216
|
+
setIsAnimating(true);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
} else {
|
|
220
|
+
setIsAnimating(false);
|
|
221
|
+
const timer = setTimeout(() => {
|
|
222
|
+
setShouldRender(false);
|
|
223
|
+
setMessages([]);
|
|
224
|
+
setInput('');
|
|
225
|
+
setError(null);
|
|
226
|
+
initialQuestionSent.current = false;
|
|
227
|
+
}, 300);
|
|
228
|
+
return () => clearTimeout(timer);
|
|
229
|
+
}
|
|
230
|
+
}, [isOpen]);
|
|
231
|
+
|
|
232
|
+
// Send initial question when opened
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (isOpen && initialQuestion && !initialQuestionSent.current) {
|
|
235
|
+
initialQuestionSent.current = true;
|
|
236
|
+
if (initialQuestion.trim()) {
|
|
237
|
+
setTimeout(() => sendMessageRef.current?.(initialQuestion), 100);
|
|
238
|
+
} else {
|
|
239
|
+
setTimeout(() => inputRef.current?.focus(), 350);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}, [isOpen, initialQuestion]);
|
|
243
|
+
|
|
244
|
+
// Scroll to bottom on new messages
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
247
|
+
}, [messages]);
|
|
248
|
+
|
|
249
|
+
// ESC to close
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
const handleEsc = (e: KeyboardEvent) => {
|
|
252
|
+
if (e.key === 'Escape' && isOpen) onClose();
|
|
253
|
+
};
|
|
254
|
+
if (isOpen) {
|
|
255
|
+
document.addEventListener('keydown', handleEsc);
|
|
256
|
+
return () => document.removeEventListener('keydown', handleEsc);
|
|
257
|
+
}
|
|
258
|
+
}, [isOpen, onClose]);
|
|
259
|
+
|
|
260
|
+
// --- Handlers ---
|
|
261
|
+
|
|
262
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
263
|
+
e.preventDefault();
|
|
264
|
+
if (input.trim()) {
|
|
265
|
+
sendMessage(input.trim());
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// --- Render ---
|
|
270
|
+
|
|
271
|
+
if (!shouldRender) return null;
|
|
272
|
+
|
|
273
|
+
const drawerContent = (
|
|
274
|
+
<div
|
|
275
|
+
className={cn(
|
|
276
|
+
'fixed inset-0 z-50 transition-opacity duration-300',
|
|
277
|
+
isAnimating ? 'opacity-100' : 'opacity-0'
|
|
278
|
+
)}
|
|
279
|
+
>
|
|
280
|
+
{/* Overlay */}
|
|
281
|
+
<div
|
|
282
|
+
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
|
283
|
+
onClick={onClose}
|
|
284
|
+
/>
|
|
285
|
+
|
|
286
|
+
{/* Drawer — bottom sheet on mobile, right drawer on desktop */}
|
|
287
|
+
<div
|
|
288
|
+
className={cn(
|
|
289
|
+
'absolute bg-white flex flex-col transition-transform duration-300 ease-out',
|
|
290
|
+
// Mobile: bottom sheet
|
|
291
|
+
'inset-x-0 bottom-0 top-[10vh] rounded-t-2xl',
|
|
292
|
+
// Desktop: right side drawer
|
|
293
|
+
'sm:inset-y-0 sm:right-0 sm:left-auto sm:w-[420px] sm:max-w-full sm:rounded-t-none sm:rounded-l-2xl',
|
|
294
|
+
// Animation
|
|
295
|
+
isAnimating
|
|
296
|
+
? 'translate-y-0 sm:translate-x-0'
|
|
297
|
+
: 'translate-y-full sm:translate-y-0 sm:translate-x-full',
|
|
298
|
+
className
|
|
299
|
+
)}
|
|
300
|
+
>
|
|
301
|
+
{/* Header */}
|
|
302
|
+
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 flex-shrink-0">
|
|
303
|
+
<div className="flex items-center gap-2">
|
|
304
|
+
<Sparkles className="h-5 w-5 text-orange-500" />
|
|
305
|
+
<span className="font-semibold text-gray-900">{assistantName}</span>
|
|
306
|
+
<span className="text-xs text-gray-400 bg-gray-100 rounded px-1.5 py-0.5">beta</span>
|
|
307
|
+
</div>
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
onClick={onClose}
|
|
311
|
+
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
|
312
|
+
aria-label="Close"
|
|
313
|
+
>
|
|
314
|
+
<X className="h-5 w-5" />
|
|
315
|
+
</button>
|
|
316
|
+
</div>
|
|
317
|
+
|
|
318
|
+
{/* Messages */}
|
|
319
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0">
|
|
320
|
+
{messages.length === 0 && !isStreaming && (
|
|
321
|
+
<div className="text-center text-gray-400 text-sm py-8">
|
|
322
|
+
Ask me anything about our products!
|
|
323
|
+
</div>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{messages.map((msg, i) => (
|
|
327
|
+
<div key={i}>
|
|
328
|
+
{msg.role === 'user' ? (
|
|
329
|
+
<div className="flex justify-end">
|
|
330
|
+
<div className="bg-gray-800 text-white rounded-2xl rounded-br-sm px-4 py-2 max-w-[80%] text-sm">
|
|
331
|
+
{msg.content}
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
) : (
|
|
335
|
+
<div className="space-y-3">
|
|
336
|
+
{/* Text answer */}
|
|
337
|
+
<div className="text-sm text-gray-800 leading-relaxed whitespace-pre-wrap">
|
|
338
|
+
{msg.content}
|
|
339
|
+
{isStreaming && i === messages.length - 1 && (
|
|
340
|
+
<span className="inline-block w-1.5 h-4 bg-gray-400 animate-pulse ml-0.5 align-text-bottom" />
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
{/* Product cards */}
|
|
345
|
+
{msg.products && msg.products.length > 0 && (
|
|
346
|
+
<div className="space-y-2">
|
|
347
|
+
{msg.products.map((product) => (
|
|
348
|
+
<button
|
|
349
|
+
key={product.slug}
|
|
350
|
+
type="button"
|
|
351
|
+
onClick={() => onProductClick?.(product.slug)}
|
|
352
|
+
className="flex items-center gap-3 w-full rounded-xl border border-gray-200 p-2 hover:bg-gray-50 transition-colors text-left"
|
|
353
|
+
>
|
|
354
|
+
{product.thumbnailUrl ? (
|
|
355
|
+
<img
|
|
356
|
+
src={product.thumbnailUrl}
|
|
357
|
+
alt={product.name}
|
|
358
|
+
className="h-16 w-16 rounded-lg object-cover flex-shrink-0"
|
|
359
|
+
/>
|
|
360
|
+
) : (
|
|
361
|
+
<div className="h-16 w-16 rounded-lg bg-gray-100 flex-shrink-0" />
|
|
362
|
+
)}
|
|
363
|
+
<div className="min-w-0">
|
|
364
|
+
<p className="text-sm font-medium text-gray-900 line-clamp-2">
|
|
365
|
+
{product.name}
|
|
366
|
+
</p>
|
|
367
|
+
<p className="text-sm font-semibold text-blue-600">
|
|
368
|
+
{formatCurrency(product.price)}
|
|
369
|
+
</p>
|
|
370
|
+
</div>
|
|
371
|
+
</button>
|
|
372
|
+
))}
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
))}
|
|
379
|
+
|
|
380
|
+
{/* Error */}
|
|
381
|
+
{error && (
|
|
382
|
+
<div className="rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
|
383
|
+
{error}
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
|
|
387
|
+
<div ref={messagesEndRef} />
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
{/* Input */}
|
|
391
|
+
<div className="border-t border-gray-200 p-3 flex-shrink-0">
|
|
392
|
+
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
|
393
|
+
<input
|
|
394
|
+
ref={inputRef}
|
|
395
|
+
type="text"
|
|
396
|
+
value={input}
|
|
397
|
+
onChange={(e) => setInput(e.target.value)}
|
|
398
|
+
placeholder={`Ask ${assistantName} a question`}
|
|
399
|
+
disabled={isStreaming}
|
|
400
|
+
className={cn(
|
|
401
|
+
'flex-1 rounded-full border border-gray-300 px-4 py-2.5 text-sm',
|
|
402
|
+
'focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-400/30',
|
|
403
|
+
'disabled:opacity-50'
|
|
404
|
+
)}
|
|
405
|
+
/>
|
|
406
|
+
<button
|
|
407
|
+
type="submit"
|
|
408
|
+
disabled={!input.trim() || isStreaming}
|
|
409
|
+
className={cn(
|
|
410
|
+
'flex h-10 w-10 items-center justify-center rounded-full',
|
|
411
|
+
'bg-gray-800 text-white',
|
|
412
|
+
'hover:bg-gray-700 transition-colors',
|
|
413
|
+
'disabled:opacity-40 disabled:cursor-not-allowed'
|
|
414
|
+
)}
|
|
415
|
+
>
|
|
416
|
+
{isStreaming ? (
|
|
417
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
418
|
+
) : (
|
|
419
|
+
<Send className="h-4 w-4" />
|
|
420
|
+
)}
|
|
421
|
+
</button>
|
|
422
|
+
</form>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
427
|
+
|
|
428
|
+
if (disablePortal) return drawerContent;
|
|
429
|
+
return createPortal(drawerContent, document.body);
|
|
430
|
+
}
|
|
@@ -409,6 +409,7 @@ export function Checkout({
|
|
|
409
409
|
const { firstName: psFirstName, lastName: psLastName } = splitFullName(formData.fullName);
|
|
410
410
|
paystack.initializePayment({
|
|
411
411
|
email: cart?.customerEmail || `${formData.phone.replace(/\D/g, '')}@customer.instock.ng`,
|
|
412
|
+
phone: formData.phone,
|
|
412
413
|
amount: amountInKobo,
|
|
413
414
|
currency: 'NGN',
|
|
414
415
|
metadata: {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { ProductAssistantChips } from './ProductAssistantChips';
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Components/ProductAssistantChips',
|
|
6
|
+
component: ProductAssistantChips,
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'centered',
|
|
9
|
+
},
|
|
10
|
+
tags: ['autodocs'],
|
|
11
|
+
} satisfies Meta<typeof ProductAssistantChips>;
|
|
12
|
+
|
|
13
|
+
export default meta;
|
|
14
|
+
type Story = StoryObj<typeof meta>;
|
|
15
|
+
|
|
16
|
+
const mockFaqs = [
|
|
17
|
+
{ question: 'Are these leggings stretchy?', answer: 'Yes, they are made with 4-way stretch fabric.' },
|
|
18
|
+
{ question: 'Do they have pockets?', answer: 'Yes, two side pockets deep enough for a phone.' },
|
|
19
|
+
{ question: 'Can they be worn during workouts?', answer: 'Absolutely, they are designed for high-intensity workouts.' },
|
|
20
|
+
{ question: 'What sizes are available?', answer: 'Available in S, M, L, and XL.' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export const Default: Story = {
|
|
24
|
+
args: {
|
|
25
|
+
faqs: mockFaqs,
|
|
26
|
+
onAsk: (question) => console.log('Asked:', question),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const FewQuestions: Story = {
|
|
31
|
+
args: {
|
|
32
|
+
faqs: mockFaqs.slice(0, 2),
|
|
33
|
+
onAsk: (question) => console.log('Asked:', question),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const NoFaqs: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
faqs: [],
|
|
40
|
+
onAsk: (question) => console.log('Asked:', question),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ProductAssistantChips Component
|
|
5
|
+
*
|
|
6
|
+
* Displays FAQ-based question pills under a product image.
|
|
7
|
+
* Clicking a chip opens the AssistantDrawer with that question pre-sent.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Sparkles } from 'lucide-react';
|
|
11
|
+
import { cn } from '../lib/utils';
|
|
12
|
+
|
|
13
|
+
export interface ProductAssistantChipsProps {
|
|
14
|
+
/** FAQ questions to display as chips */
|
|
15
|
+
faqs?: Array<{ question: string; answer: string }>;
|
|
16
|
+
/** Callback when a chip is clicked — should open the drawer with this question */
|
|
17
|
+
onAsk: (question: string) => void;
|
|
18
|
+
/** Custom class name */
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ProductAssistantChips({
|
|
23
|
+
faqs = [],
|
|
24
|
+
onAsk,
|
|
25
|
+
className,
|
|
26
|
+
}: ProductAssistantChipsProps) {
|
|
27
|
+
if (faqs.length === 0) return null;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className={cn('space-y-2', className)}>
|
|
31
|
+
<div className="flex items-center gap-1.5 text-sm font-medium text-gray-700">
|
|
32
|
+
<Sparkles className="h-4 w-4 text-orange-500" />
|
|
33
|
+
Ask AI
|
|
34
|
+
</div>
|
|
35
|
+
<div className="flex flex-wrap gap-2">
|
|
36
|
+
{faqs.map((faq, i) => (
|
|
37
|
+
<button
|
|
38
|
+
key={i}
|
|
39
|
+
type="button"
|
|
40
|
+
onClick={() => onAsk(faq.question)}
|
|
41
|
+
className={cn(
|
|
42
|
+
'rounded-full border border-gray-200 bg-blue-50/60 px-3 py-1.5 text-sm text-gray-700',
|
|
43
|
+
'hover:bg-blue-100 hover:border-blue-200 transition-colors',
|
|
44
|
+
'active:scale-95'
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
{faq.question}
|
|
48
|
+
</button>
|
|
49
|
+
))}
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={() => onAsk('')}
|
|
53
|
+
className={cn(
|
|
54
|
+
'rounded-full border border-gray-300 bg-gray-800 px-3 py-1.5 text-sm text-white font-medium',
|
|
55
|
+
'hover:bg-gray-700 transition-colors',
|
|
56
|
+
'active:scale-95'
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
Ask something else
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|