keystone-design-bootstrap 1.0.84 → 1.0.86

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.84",
3
+ "version": "1.0.86",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -64,6 +64,7 @@
64
64
  "@fontsource/montserrat": "^5.2.8",
65
65
  "@fontsource/playfair-display": "^5.2.8",
66
66
  "@fontsource/poppins": "^5.2.7",
67
+ "@rails/actioncable": "^8.1.300",
67
68
  "@untitledui/file-icons": "^0.0.9",
68
69
  "@untitledui/icons": "^0.0.20",
69
70
  "clsx": "^2.1.1",
@@ -79,6 +80,7 @@
79
80
  },
80
81
  "devDependencies": {
81
82
  "@types/node": "^20",
83
+ "@types/rails__actioncable": "^8.0.3",
82
84
  "@types/react": "^19",
83
85
  "@types/react-dom": "^19",
84
86
  "eslint": "^9",
@@ -0,0 +1,127 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef } from 'react';
4
+ import { createConsumer } from '@rails/actioncable';
5
+
6
+ export interface RealtimeSubscriptionData {
7
+ token: string;
8
+ contact_id: number;
9
+ cable_url: string;
10
+ }
11
+
12
+ interface UseRealtimeReplyOrchestratorOptions {
13
+ debugLabel: string;
14
+ fetchRealtimeData: () => Promise<RealtimeSubscriptionData | null>;
15
+ loadLatestHasReply: () => Promise<boolean>;
16
+ onReplyResolved: () => void;
17
+ onAgentThinking?: () => void;
18
+ }
19
+
20
+ export function useRealtimeReplyOrchestrator({
21
+ debugLabel,
22
+ fetchRealtimeData,
23
+ loadLatestHasReply,
24
+ onReplyResolved,
25
+ onAgentThinking,
26
+ }: UseRealtimeReplyOrchestratorOptions) {
27
+ const cableRef = useRef<ReturnType<typeof createConsumer> | null>(null);
28
+ const subscriptionRef = useRef<{ unsubscribe: () => void } | null>(null);
29
+ const subscribedContactRef = useRef<number | null>(null);
30
+
31
+ const clearRealtime = useCallback(() => {
32
+ if (subscriptionRef.current) {
33
+ subscriptionRef.current.unsubscribe();
34
+ subscriptionRef.current = null;
35
+ }
36
+ if (cableRef.current) {
37
+ cableRef.current.disconnect();
38
+ cableRef.current = null;
39
+ }
40
+ subscribedContactRef.current = null;
41
+ }, []);
42
+
43
+ const resolveReply = useCallback(() => {
44
+ onReplyResolved();
45
+ }, [onReplyResolved]);
46
+
47
+ const subscribeRealtime = useCallback((realtime: RealtimeSubscriptionData) => {
48
+ const token = realtime.token;
49
+ const contactIdForStream = realtime.contact_id;
50
+ const cableUrl = realtime.cable_url;
51
+ if (!token || !contactIdForStream || !cableUrl) return;
52
+
53
+ if (
54
+ subscribedContactRef.current === contactIdForStream &&
55
+ subscriptionRef.current &&
56
+ cableRef.current
57
+ ) {
58
+ return;
59
+ }
60
+
61
+ clearRealtime();
62
+
63
+ const cable = createConsumer(`${cableUrl}?token=${encodeURIComponent(token)}`);
64
+ cableRef.current = cable;
65
+ subscribedContactRef.current = contactIdForStream;
66
+ subscriptionRef.current = cable.subscriptions.create(
67
+ { channel: 'ContactCommunicationsChannel', contact_id: String(contactIdForStream) },
68
+ {
69
+ connected: () => {
70
+ console.info(`[${debugLabel}] realtime connected contact_id=${contactIdForStream}`);
71
+ },
72
+ disconnected: () => {
73
+ console.warn(`[${debugLabel}] realtime disconnected contact_id=${contactIdForStream}`);
74
+ },
75
+ rejected: () => {
76
+ console.warn(`[${debugLabel}] realtime rejected contact_id=${contactIdForStream}`);
77
+ },
78
+ received: async (data: { type?: string }) => {
79
+ console.info(`[${debugLabel}] realtime received type=${data?.type ?? 'unknown'} contact_id=${contactIdForStream}`);
80
+ if (data?.type === 'agent_thinking') {
81
+ onAgentThinking?.();
82
+ return;
83
+ }
84
+ if (data?.type !== 'new_communication') return;
85
+ try {
86
+ const hasReply = await loadLatestHasReply();
87
+ if (hasReply) {
88
+ resolveReply();
89
+ }
90
+ } catch (error) {
91
+ console.error(`[${debugLabel}] realtime receive handling error`, error);
92
+ }
93
+ },
94
+ }
95
+ );
96
+ }, [clearRealtime, debugLabel, loadLatestHasReply, onAgentThinking, resolveReply]);
97
+
98
+ const ensureRealtimeSubscription = useCallback(async () => {
99
+ try {
100
+ const realtime = await fetchRealtimeData();
101
+ if (!realtime) return;
102
+ subscribeRealtime(realtime);
103
+ } catch {
104
+ // Realtime is best-effort; UI remains in waiting state until connection succeeds.
105
+ }
106
+ }, [fetchRealtimeData, subscribeRealtime]);
107
+
108
+ const beginReplyWait = useCallback((options?: { realtimeData?: RealtimeSubscriptionData | null }) => {
109
+ if (options?.realtimeData) {
110
+ subscribeRealtime(options.realtimeData);
111
+ } else {
112
+ void ensureRealtimeSubscription();
113
+ }
114
+ }, [ensureRealtimeSubscription, subscribeRealtime]);
115
+
116
+ useEffect(() => {
117
+ return () => {
118
+ clearRealtime();
119
+ };
120
+ }, [clearRealtime]);
121
+
122
+ return {
123
+ ensureRealtimeSubscription,
124
+ subscribeRealtime,
125
+ beginReplyWait,
126
+ };
127
+ }
@@ -5,6 +5,7 @@ import { X, MessageChatSquare } from '@untitledui/icons';
5
5
  import { Avatar } from '../elements/avatar/avatar';
6
6
  import { cx } from '../../utils/cx';
7
7
  import { captureEvent } from '../../tracking/captureEvent';
8
+ import { useRealtimeReplyOrchestrator, type RealtimeSubscriptionData } from '../chat/useRealtimeReplyOrchestrator';
8
9
 
9
10
  interface Message {
10
11
  id: string;
@@ -126,47 +127,55 @@ export function ChatWidget({
126
127
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
127
128
  }, [messages]);
128
129
 
129
- // Poll for agent reply (simpler than WebSockets for public widget)
130
- const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
131
-
132
- // Clear any in-flight poll on unmount
133
- useEffect(() => {
134
- return () => {
135
- if (pollIntervalRef.current !== null) {
136
- clearInterval(pollIntervalRef.current);
137
- }
138
- };
130
+ const hasAgentReplyWithBody = useCallback((list: Message[]) => {
131
+ const latest = list[list.length - 1];
132
+ return (
133
+ latest?.sender_type === 'agent' &&
134
+ latest?.body != null &&
135
+ String(latest.body).trim() !== ''
136
+ );
139
137
  }, []);
140
138
 
141
- const pollForAgentReply = () => {
142
- setWaitingForReply(true);
143
-
144
- let attempts = 0;
145
- const maxAttempts = 30;
146
-
147
- pollIntervalRef.current = setInterval(async () => {
148
- attempts++;
139
+ const fetchRealtimeData = useCallback(async (): Promise<RealtimeSubscriptionData | null> => {
140
+ if (!contactId && !sessionId) return null;
141
+ const query = contactId
142
+ ? `contact_id=${encodeURIComponent(contactId)}`
143
+ : `identifier=${encodeURIComponent(sessionId)}`;
144
+ const response = await fetch(`/api/chat/?action=realtime_token&${query}`);
145
+ if (!response.ok) return null;
146
+ const result = await response.json();
147
+ const token = result?.data?.token as string | undefined;
148
+ const contactIdForStream = result?.data?.contact_id as number | undefined;
149
+ const cableUrl = result?.data?.cable_url as string | undefined;
150
+ if (!token || !contactIdForStream || !cableUrl) return null;
151
+ return { token, contact_id: contactIdForStream, cable_url: cableUrl };
152
+ }, [contactId, sessionId]);
149
153
 
150
- try {
151
- const newMessages = await loadMessages();
154
+ const {
155
+ beginReplyWait,
156
+ ensureRealtimeSubscription,
157
+ } = useRealtimeReplyOrchestrator({
158
+ debugLabel: 'ChatWidget',
159
+ fetchRealtimeData,
160
+ loadLatestHasReply: async () => hasAgentReplyWithBody(await loadMessages()),
161
+ onReplyResolved: () => {
162
+ setWaitingForReply(false);
163
+ setIsLoading(false);
164
+ },
165
+ onAgentThinking: () => {
166
+ setWaitingForReply(true);
167
+ },
168
+ });
152
169
 
153
- const latest = newMessages[newMessages.length - 1];
154
- const hasAgentReplyWithBody =
155
- latest?.sender_type === 'agent' &&
156
- latest?.body != null &&
157
- String(latest.body).trim() !== '';
170
+ const hasPersistedMessages = messages.some((message) => !String(message.id).startsWith('temp_'));
158
171
 
159
- if (hasAgentReplyWithBody || attempts >= maxAttempts) {
160
- clearInterval(pollIntervalRef.current!);
161
- pollIntervalRef.current = null;
162
- setWaitingForReply(false);
163
- setIsLoading(false);
164
- }
165
- } catch (error) {
166
- console.error('[ChatWidget] Error polling for messages:', error);
167
- }
168
- }, 1000);
169
- };
172
+ useEffect(() => {
173
+ if (!isOpen || (!contactId && !sessionId)) return;
174
+ // Anonymous sessions do not have a contact row until the first message is sent.
175
+ // Ignore optimistic temp rows to avoid pre-contact token probes (404 noise).
176
+ if (!contactId && !hasPersistedMessages) return;
177
+ ensureRealtimeSubscription();
178
+ }, [contactId, ensureRealtimeSubscription, hasPersistedMessages, isOpen, sessionId]);
170
179
 
171
180
  const sendMessage = async () => {
172
181
  if (!inputValue.trim() || (!contactId && !sessionId)) return;
@@ -201,24 +210,36 @@ export function ChatWidget({
201
210
  captureEvent('chat_message_sent', { is_authenticated: Boolean(contactId) });
202
211
 
203
212
  if (result.data?.job_id) {
204
- pollForAgentReply();
213
+ setWaitingForReply(true);
214
+ const realtimeFromSend = result.data?.realtime_token && result.data?.contact_id && result.data?.cable_url
215
+ ? {
216
+ token: result.data.realtime_token,
217
+ contact_id: result.data.contact_id,
218
+ cable_url: result.data.cable_url,
219
+ }
220
+ : null;
221
+ beginReplyWait({ realtimeData: realtimeFromSend });
205
222
  } else if (result.data?.status === 'agent_unavailable' || result.data?.status === 'no_auto_reply') {
206
223
  setIsLoading(false);
224
+ setWaitingForReply(false);
207
225
  } else {
208
226
  await loadMessages();
209
227
  setIsLoading(false);
228
+ setWaitingForReply(false);
210
229
  }
211
230
  } else {
212
231
  setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
213
232
  captureEvent('chat_message_failed', { error: 'send_failed' });
214
233
  console.error('Failed to send message');
215
234
  setIsLoading(false);
235
+ setWaitingForReply(false);
216
236
  }
217
237
  } catch (error) {
218
238
  setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
219
239
  captureEvent('chat_message_failed', { error: 'network_error' });
220
240
  console.error('Failed to send message:', error);
221
241
  setIsLoading(false);
242
+ setWaitingForReply(false);
222
243
  }
223
244
  };
224
245
 
@@ -0,0 +1,17 @@
1
+ <svg width="36" height="41" viewBox="0 0 36 41" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <g clip-path="url(#clip0_1455_1264)">
3
+ <mask id="mask0_1455_1264" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="36" height="41">
4
+ <path d="M36 0H0V41H36V0Z" fill="white"/>
5
+ </mask>
6
+ <g mask="url(#mask0_1455_1264)">
7
+ <path d="M36.0009 13.6677L24.0031 20.4999L36.0009 27.3321V40.9966L11.998 27.3288V13.671L36.0009 0.0032959V13.6677Z" fill="black"/>
8
+ <path d="M11.9971 27.3288V40.9958L0 34.1644V20.4973L11.9971 27.3288Z" fill="black"/>
9
+ <path d="M11.9971 13.671L0.00476074 20.4999L0 20.4973V6.83551L11.9971 0.00402832V13.671Z" fill="black"/>
10
+ </g>
11
+ </g>
12
+ <defs>
13
+ <clipPath id="clip0_1455_1264">
14
+ <rect width="36" height="41" fill="white"/>
15
+ </clipPath>
16
+ </defs>
17
+ </svg>
@@ -0,0 +1,22 @@
1
+ <svg width="104" height="20" viewBox="0 0 104 20" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <g clip-path="url(#clip0_1455_1272)">
3
+ <mask id="mask0_1455_1272" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="104" height="20">
4
+ <path d="M104 0H0V20H104V0Z" fill="white"/>
5
+ </mask>
6
+ <g mask="url(#mask0_1455_1272)">
7
+ <path d="M3.14032 0L3.1612 8.23754H3.82871L9.47236 2.94575H13.0328V3.34299L6.45695 9.52024L13.2946 16.1684V16.5656H9.53903L3.8267 11.0811H3.15919V16.5584H0V0H3.14032Z" fill="black"/>
8
+ <path d="M18.4299 2.62219C22.4643 2.62219 24.8242 5.10147 24.8242 9.20756V10.6049H14.6121C14.6635 12.8878 16.0848 14.486 18.4167 14.486C20.7485 14.486 21.6835 13.2718 21.8892 12.243H24.5535V12.6403C24.1957 14.422 22.5534 16.8876 18.4552 16.8876C14.357 16.8876 11.7031 14.4462 11.7031 9.7505C11.7031 5.05478 14.3675 2.62662 18.4295 2.62662L18.4299 2.62219ZM21.9647 8.28025C21.939 6.42119 20.7365 4.9932 18.3914 4.9932C16.0463 4.9932 14.8181 6.45903 14.6506 8.28025H21.9667H21.9647Z" fill="black"/>
9
+ <path d="M27.5146 2.93809L31.2554 12.994H31.459L35.1865 2.93809H38.2369V3.33534L31.565 19.9996H28.5147V19.6024L29.7417 16.5942L24.3848 3.33292V2.93567H27.5142L27.5146 2.93809Z" fill="black"/>
10
+ <path d="M44.1191 2.63538C47.759 2.63538 49.8832 4.19177 50.1017 6.73947V7.13671H47.3345C47.2445 5.53847 45.965 4.98667 44.1319 4.98667C42.2989 4.98667 41.3129 5.54088 41.3129 6.5982C41.3129 7.65551 42.1338 7.9956 43.3002 8.18115L45.8103 8.57839C48.4747 9.0022 50.2796 10.1214 50.2796 12.6027C50.2796 15.084 48.3591 16.8767 44.4898 16.8767C40.6205 16.8767 38.3782 15.082 38.1855 12.6667V12.2695H41.0045C41.133 13.8677 42.5692 14.5258 44.4898 14.5258C46.4104 14.5258 47.3859 13.8391 47.3859 12.7706C47.3859 11.702 46.5521 11.2782 45.1332 11.0552L42.623 10.658C40.1129 10.2607 38.4324 9.12818 38.4324 6.66219C38.4324 4.1962 40.4944 2.6378 44.1215 2.6378H44.1195L44.1191 2.63538Z" fill="black"/>
11
+ <path d="M54.2257 2.93729V0H57.2632V2.93729H62.247V5.35257H57.2632V13.7724L57.4668 13.9821H62.1679V16.5673H57.8765C55.5828 16.5673 54.2237 15.353 54.2237 13.0327V5.35217H50.2773V2.93689L54.2257 2.93729Z" fill="black"/>
12
+ <path d="M69.219 2.62219C73.3325 2.62219 76.2025 5.13004 76.2025 9.74608C76.2025 14.3621 73.3325 16.8832 69.219 16.8832C65.1056 16.8832 62.25 14.3754 62.25 9.74608C62.25 5.11676 65.1052 2.62219 69.219 2.62219ZM69.219 14.4442C71.6003 14.4442 73.1265 12.7687 73.1265 9.74648C73.1265 6.72426 71.6023 5.06202 69.219 5.06202C66.8358 5.06202 65.3241 6.73754 65.3241 9.74648C65.3241 12.7554 66.8482 14.4442 69.219 14.4442Z" fill="black"/>
13
+ <path d="M80.2535 2.94007V4.45661H80.4568C81.3785 3.16304 82.8641 2.64868 84.6606 2.64868C87.7989 2.64868 89.7472 4.29763 89.7472 7.66236V16.5681H86.7097V7.93846C86.7097 6.03755 85.698 5.15451 83.8907 5.15451C81.9315 5.15451 80.3539 6.30238 80.3539 8.95392V16.566H77.3164V2.94007H80.2535Z" fill="black"/>
14
+ <path d="M97.6065 2.62219C101.64 2.62219 104 5.10147 104 9.20756V10.6049H93.7886C93.838 12.8878 95.261 14.486 97.5933 14.486C99.9255 14.486 100.859 13.2718 101.065 12.243H103.729V12.6403C103.371 14.422 101.73 16.8876 97.6314 16.8876C93.5328 16.8876 90.8789 14.4462 90.8789 9.7505C90.8789 5.05478 93.5437 2.62662 97.6057 2.62662L97.6065 2.62219ZM101.141 8.28025C101.115 6.42119 99.9127 4.9932 97.568 4.9932C95.2233 4.9932 93.9943 6.45903 93.8272 8.28025H101.143H101.141Z" fill="black"/>
15
+ </g>
16
+ </g>
17
+ <defs>
18
+ <clipPath id="clip0_1455_1272">
19
+ <rect width="104" height="20" fill="white"/>
20
+ </clipPath>
21
+ </defs>
22
+ </svg>
@@ -0,0 +1,25 @@
1
+ "use client";
2
+
3
+ import type { HTMLAttributes } from "react";
4
+ import { cx } from "../../utils/cx";
5
+ import { KeystoneLogoMinimal } from "./keystone-logo-minimal";
6
+ import { KeystoneWordmark } from "./keystone-wordmark";
7
+
8
+ type KeystoneBrandLockupProps = HTMLAttributes<HTMLDivElement> & {
9
+ iconClassName?: string;
10
+ wordmarkClassName?: string;
11
+ };
12
+
13
+ export function KeystoneBrandLockup({
14
+ className,
15
+ iconClassName,
16
+ wordmarkClassName,
17
+ ...props
18
+ }: KeystoneBrandLockupProps) {
19
+ return (
20
+ <div {...props} className={cx("flex flex-col items-start gap-2", className)}>
21
+ <KeystoneLogoMinimal className={cx("h-4 w-auto shrink-0", iconClassName)} />
22
+ <KeystoneWordmark className={cx("h-4 w-auto shrink-0", wordmarkClassName)} />
23
+ </div>
24
+ );
25
+ }
@@ -1,93 +1,32 @@
1
1
  "use client";
2
2
 
3
3
  import type { SVGProps } from "react";
4
- import { useId } from "react";
5
- import { cx } from '../../utils/cx';
4
+ import { cx } from "../../utils/cx";
6
5
 
7
- export const KeystoneLogoMinimal = (props: SVGProps<SVGSVGElement>) => {
8
- const id = useId();
9
-
10
- return (
11
- <svg viewBox="0 0 38 38" fill="none" {...props} className={cx("size-8 origin-center scale-[1.2]", props.className)}>
12
- <g filter={`url(#filter0-${id})`}>
13
- <g clipPath={`url(#clip0-${id})`}>
14
- <path
15
- d="M3 14.8C3 10.3196 3 8.07937 3.87195 6.36808C4.63893 4.86278 5.86278 3.63893 7.36808 2.87195C9.07937 2 11.3196 2 15.8 2H22.2C26.6804 2 28.9206 2 30.6319 2.87195C32.1372 3.63893 33.3611 4.86278 34.1281 6.36808C35 8.07937 35 10.3196 35 14.8V21.2C35 25.6804 35 27.9206 34.1281 29.6319C33.3611 31.1372 32.1372 32.3611 30.6319 33.1281C28.9206 34 26.6804 34 22.2 34H15.8C11.3196 34 9.07937 34 7.36808 33.1281C5.86278 32.3611 4.63893 31.1372 3.87195 29.6319C3 27.9206 3 25.6804 3 21.2V14.8Z"
16
- fill="white"
17
- />
18
- <path
19
- d="M3 14.8C3 10.3196 3 8.07937 3.87195 6.36808C4.63893 4.86278 5.86278 3.63893 7.36808 2.87195C9.07937 2 11.3196 2 15.8 2H22.2C26.6804 2 28.9206 2 30.6319 2.87195C32.1372 3.63893 33.3611 4.86278 34.1281 6.36808C35 8.07937 35 10.3196 35 14.8V21.2C35 25.6804 35 27.9206 34.1281 29.6319C33.3611 31.1372 32.1372 32.3611 30.6319 33.1281C28.9206 34 26.6804 34 22.2 34H15.8C11.3196 34 9.07937 34 7.36808 33.1281C5.86278 32.3611 4.63893 31.1372 3.87195 29.6319C3 27.9206 3 25.6804 3 21.2V14.8Z"
20
- fill={`url(#paint0_linear-${id})`}
21
- fillOpacity="0.2"
22
- />
23
- {/* Clean pistachio circle */}
24
- <circle cx="19" cy="19" r="8" fill={`url(#paint1_linear-${id})`} />
25
- </g>
26
- <path
27
- d="M3.1 14.8C3.1 12.5581 3.10008 10.8828 3.20866 9.55376C3.31715 8.22593 3.53345 7.25268 3.96105 6.41348C4.71845 4.92699 5.92699 3.71845 7.41348 2.96105C8.25268 2.53345 9.22593 2.31715 10.5538 2.20866C11.8828 2.10008 13.5581 2.1 15.8 2.1H22.2C24.4419 2.1 26.1172 2.10008 27.4462 2.20866C28.7741 2.31715 29.7473 2.53345 30.5865 2.96105C32.073 3.71845 33.2816 4.92699 34.039 6.41348C34.4665 7.25268 34.6828 8.22593 34.7913 9.55376C34.8999 10.8828 34.9 12.5581 34.9 14.8V21.2C34.9 23.4419 34.8999 25.1172 34.7913 26.4462C34.6828 27.7741 34.4665 28.7473 34.039 29.5865C33.2816 31.073 32.073 32.2816 30.5865 33.039C29.7473 33.4665 28.7741 33.6828 27.4462 33.7913C26.1172 33.8999 24.4419 33.9 22.2 33.9H15.8C13.5581 33.9 11.8828 33.8999 10.5538 33.7913C9.22593 33.6828 8.25268 33.4665 7.41348 33.039C5.92699 32.2816 4.71845 31.073 3.96105 29.5865C3.53345 28.7473 3.31715 27.7741 3.20866 26.4462C3.10008 25.1172 3.1 23.4419 3.1 21.2V14.8Z"
28
- stroke="#0A0D12"
29
- strokeOpacity="0.12"
30
- strokeWidth="0.2"
31
- />
32
- </g>
6
+ export const KEYSTONE_BRAND_COLOR = "#6ECC8B";
7
+ export const KEYSTONE_APP_URL = "https://www.keystone.app";
33
8
 
34
- <defs>
35
- <filter id={`filter0-${id}`} x="0" y="0" width="38" height="38" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
36
- <feFlood floodOpacity="0" result="BackgroundImageFix" />
37
- <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
38
- <feOffset dy="1" />
39
- <feGaussianBlur stdDeviation="1" />
40
- <feColorMatrix type="matrix" values="0 0 0 0 0.0392157 0 0 0 0 0.0509804 0 0 0 0 0.0705882 0 0 0 0.06 0" />
41
- <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow" />
42
- <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
43
- <feOffset dy="1" />
44
- <feGaussianBlur stdDeviation="1.5" />
45
- <feColorMatrix type="matrix" values="0 0 0 0 0.0392157 0 0 0 0 0.0509804 0 0 0 0 0.0705882 0 0 0 0.1 0" />
46
- <feBlend mode="normal" in2="effect2_dropShadow" result="effect2_dropShadow" />
47
- <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
48
- <feMorphology radius="0.5" operator="erode" in="SourceAlpha" result="effect3_dropShadow" />
49
- <feOffset dy="1" />
50
- <feGaussianBlur stdDeviation="0.5" />
51
- <feComposite in2="hardAlpha" operator="out" />
52
- <feColorMatrix type="matrix" values="0 0 0 0 0.0392157 0 0 0 0 0.0509804 0 0 0 0 0.0705882 0 0 0 0.13 0" />
53
- <feBlend mode="normal" in2="effect2_dropShadow" result="effect3_dropShadow" />
54
- <feBlend mode="normal" in="SourceGraphic" in2="effect3_dropShadow" result="shape" />
55
- <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
56
- <feOffset dy="-0.5" />
57
- <feGaussianBlur stdDeviation="0.25" />
58
- <feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1" />
59
- <feColorMatrix type="matrix" values="0 0 0 0 0.0392157 0 0 0 0 0.0509804 0 0 0 0 0.0705882 0 0 0 0.1 0" />
60
- <feBlend mode="normal" in2="shape" result="effect4_innerShadow" />
61
- </filter>
62
- <filter id={`filter1_dd-${id}`} x="8" y="8" width="22" height="22" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
63
- <feFlood floodOpacity="0" result="BackgroundImageFix" />
64
- <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
65
- <feOffset dy="1" />
66
- <feGaussianBlur stdDeviation="1" />
67
- <feColorMatrix type="matrix" values="0 0 0 0 0.0392157 0 0 0 0 0.0509804 0 0 0 0 0.0705882 0 0 0 0.06 0" />
68
- <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow" />
69
- <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
70
- <feOffset dy="1" />
71
- <feGaussianBlur stdDeviation="1.5" />
72
- <feColorMatrix type="matrix" values="0 0 0 0 0.0392157 0 0 0 0 0.0509804 0 0 0 0 0.0705882 0 0 0 0.1 0" />
73
- <feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow" />
74
- <feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape" />
75
- </filter>
76
- <linearGradient id={`paint0_linear-${id}`} x1="19" y1="2" x2="19" y2="34" gradientUnits="userSpaceOnUse">
77
- <stop stopColor="white" />
78
- <stop offset="1" stopColor="#0A0D12" />
79
- </linearGradient>
80
- <linearGradient id={`paint1_linear-${id}`} x1="15" y1="26" x2="23" y2="10" gradientUnits="userSpaceOnUse">
81
- <stop stopColor="#66D674" />
82
- <stop offset="1" stopColor="#42D674" />
83
- </linearGradient>
84
- <clipPath id={`clip0-${id}`}>
85
- <path
86
- d="M3 14.8C3 10.3196 3 8.07937 3.87195 6.36808C4.63893 4.86278 5.86278 3.63893 7.36808 2.87195C9.07937 2 11.3196 2 15.8 2H22.2C26.6804 2 28.9206 2 30.6319 2.87195C32.1372 3.63893 33.3611 4.86278 34.1281 6.36808C35 8.07937 35 10.3196 35 14.8V21.2C35 25.6804 35 27.9206 34.1281 29.6319C33.3611 31.1372 32.1372 32.3611 30.6319 33.1281C28.9206 34 26.6804 34 22.2 34H15.8C11.3196 34 9.07937 34 7.36808 33.1281C5.86278 32.3611 4.63893 31.1372 3.87195 29.6319C3 27.9206 3 25.6804 3 21.2V14.8Z"
87
- fill="white"
88
- />
89
- </clipPath>
90
- </defs>
91
- </svg>
92
- );
9
+ export const KeystoneLogoMinimal = (props: SVGProps<SVGSVGElement>) => {
10
+ return (
11
+ <svg
12
+ viewBox="0 0 36 41"
13
+ fill="none"
14
+ aria-hidden="true"
15
+ {...props}
16
+ className={cx("text-[#6ECC8B]", props.className)}
17
+ >
18
+ <path
19
+ d="M36 13.6677L24.0031 20.4999L36 27.3321V40.9966L11.998 27.3288V13.671L36 0.0032959V13.6677Z"
20
+ fill="currentColor"
21
+ />
22
+ <path
23
+ d="M11.9971 27.3288V40.9958L0 34.1644V20.4973L11.9971 27.3288Z"
24
+ fill="currentColor"
25
+ />
26
+ <path
27
+ d="M11.9971 13.671L0.00476074 20.4999L0 20.4973V6.83551L11.9971 0.00402832V13.671Z"
28
+ fill="currentColor"
29
+ />
30
+ </svg>
31
+ );
93
32
  };
@@ -3,20 +3,14 @@
3
3
  import type { HTMLAttributes } from "react";
4
4
  import { cx } from '../../utils/cx';
5
5
  import { KeystoneLogoMinimal } from "./keystone-logo-minimal";
6
+ import { KeystoneWordmark } from "./keystone-wordmark";
6
7
 
7
8
  export const KeystoneLogo = (props: HTMLAttributes<HTMLOrSVGElement>) => {
8
9
  return (
9
- <div {...props} className={cx("flex h-8 w-max items-center justify-start overflow-visible", props.className)}>
10
- {/* Minimal logo */}
10
+ <div {...props} className={cx("flex h-8 w-max items-stretch justify-start overflow-visible", props.className)}>
11
11
  <KeystoneLogoMinimal className="aspect-square h-full w-auto shrink-0" />
12
-
13
- {/* Gap that adjusts to the height of the container */}
14
12
  <div className="aspect-[0.4] h-full" />
15
-
16
- {/* Clean text logo */}
17
- <div className="flex items-center">
18
- <span className="text-lg font-semibold text-fg-primary">Keystone</span>
19
- </div>
13
+ <KeystoneWordmark className="h-full w-auto shrink-0" />
20
14
  </div>
21
15
  );
22
16
  };
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import type { SVGProps } from "react";
4
+ import { cx } from "../../utils/cx";
5
+
6
+ export const KeystoneWordmark = (props: SVGProps<SVGSVGElement>) => {
7
+ return (
8
+ <svg
9
+ viewBox="0 0 104 20"
10
+ fill="none"
11
+ aria-hidden="true"
12
+ {...props}
13
+ className={cx("text-[#6ECC8B]", props.className)}
14
+ >
15
+ <path
16
+ d="M3.14032 0L3.1612 8.23754H3.82871L9.47236 2.94575H13.0328V3.34299L6.45695 9.52024L13.2946 16.1684V16.5656H9.53903L3.8267 11.0811H3.15919V16.5584H0V0H3.14032Z"
17
+ fill="currentColor"
18
+ />
19
+ <path
20
+ d="M18.4299 2.62219C22.4643 2.62219 24.8242 5.10147 24.8242 9.20756V10.6049H14.6121C14.6635 12.8878 16.0848 14.486 18.4167 14.486C20.7485 14.486 21.6835 13.2718 21.8892 12.243H24.5535V12.6403C24.1957 14.422 22.5534 16.8876 18.4552 16.8876C14.357 16.8876 11.7031 14.4462 11.7031 9.7505C11.7031 5.05478 14.3675 2.62662 18.4295 2.62662L18.4299 2.62219ZM21.9647 8.28025C21.939 6.42119 20.7365 4.9932 18.3914 4.9932C16.0463 4.9932 14.8181 6.45903 14.6506 8.28025H21.9667H21.9647Z"
21
+ fill="currentColor"
22
+ />
23
+ <path
24
+ d="M27.5146 2.93809L31.2554 12.994H31.459L35.1865 2.93809H38.2369V3.33534L31.565 19.9996H28.5147V19.6024L29.7417 16.5942L24.3848 3.33292V2.93567H27.5142L27.5146 2.93809Z"
25
+ fill="currentColor"
26
+ />
27
+ <path
28
+ d="M44.1191 2.63538C47.759 2.63538 49.8832 4.19177 50.1017 6.73947V7.13671H47.3345C47.2445 5.53847 45.965 4.98667 44.1319 4.98667C42.2989 4.98667 41.3129 5.54088 41.3129 6.5982C41.3129 7.65551 42.1338 7.9956 43.3002 8.18115L45.8103 8.57839C48.4747 9.0022 50.2796 10.1214 50.2796 12.6027C50.2796 15.084 48.3591 16.8767 44.4898 16.8767C40.6205 16.8767 38.3782 15.082 38.1855 12.6667V12.2695H41.0045C41.133 13.8677 42.5692 14.5258 44.4898 14.5258C46.4104 14.5258 47.3859 13.8391 47.3859 12.7706C47.3859 11.702 46.5521 11.2782 45.1332 11.0552L42.623 10.658C40.1129 10.2607 38.4324 9.12818 38.4324 6.66219C38.4324 4.1962 40.4944 2.6378 44.1215 2.6378H44.1195L44.1191 2.63538Z"
29
+ fill="currentColor"
30
+ />
31
+ <path
32
+ d="M54.2257 2.93729V0H57.2632V2.93729H62.247V5.35257H57.2632V13.7724L57.4668 13.9821H62.1679V16.5673H57.8765C55.5828 16.5673 54.2237 15.353 54.2237 13.0327V5.35217H50.2773V2.93689L54.2257 2.93729Z"
33
+ fill="currentColor"
34
+ />
35
+ <path
36
+ d="M69.219 2.62219C73.3325 2.62219 76.2025 5.13004 76.2025 9.74608C76.2025 14.3621 73.3325 16.8832 69.219 16.8832C65.1056 16.8832 62.25 14.3754 62.25 9.74608C62.25 5.11676 65.1052 2.62219 69.219 2.62219ZM69.219 14.4442C71.6003 14.4442 73.1265 12.7687 73.1265 9.74648C73.1265 6.72426 71.6023 5.06202 69.219 5.06202C66.8358 5.06202 65.3241 6.73754 65.3241 9.74648C65.3241 12.7554 66.8482 14.4442 69.219 14.4442Z"
37
+ fill="currentColor"
38
+ />
39
+ <path
40
+ d="M80.2535 2.94007V4.45661H80.4568C81.3785 3.16304 82.8641 2.64868 84.6606 2.64868C87.7989 2.64868 89.7472 4.29763 89.7472 7.66236V16.5681H86.7097V7.93846C86.7097 6.03755 85.698 5.15451 83.8907 5.15451C81.9315 5.15451 80.3539 6.30238 80.3539 8.95392V16.566H77.3164V2.94007H80.2535Z"
41
+ fill="currentColor"
42
+ />
43
+ <path
44
+ d="M97.6065 2.62219C101.64 2.62219 104 5.10147 104 9.20756V10.6049H93.7886C93.838 12.8878 95.261 14.486 97.5933 14.486C99.9255 14.486 100.859 13.2718 101.065 12.243H103.729V12.6403C103.371 14.422 101.73 16.8876 97.6314 16.8876C93.5328 16.8876 90.8789 14.4462 90.8789 9.7505C90.8789 5.05478 93.5437 2.62662 97.6057 2.62662L97.6065 2.62219ZM101.141 8.28025C101.115 6.42119 99.9127 4.9932 97.568 4.9932C95.2233 4.9932 93.9943 6.45903 93.8272 8.28025H101.143H101.141Z"
45
+ fill="currentColor"
46
+ />
47
+ </svg>
48
+ );
49
+ };
@@ -4,15 +4,21 @@ import React, { useState, useEffect, useTransition } from 'react';
4
4
  import { useRouter } from 'next/navigation';
5
5
  import { Modal } from '../elements/modal/modal';
6
6
  import { LoginForm } from './LoginForm';
7
- import { KeystoneLogoMinimal } from '../logo/keystone-logo-minimal';
7
+ import { KEYSTONE_APP_URL, KeystoneLogoMinimal } from '../logo/keystone-logo-minimal';
8
8
 
9
9
  const keystoneFooter = (
10
10
  <div className="px-6 py-4 bg-secondary flex flex-col items-center gap-1">
11
- <div className="flex items-center gap-1.5">
12
- <KeystoneLogoMinimal className="size-5 shrink-0" />
13
- <span className="text-sm font-medium text-primary">Keystone</span>
14
- </div>
15
- <p className="text-xs text-quaternary">Powered by Keystone Universal Login</p>
11
+ <a href={KEYSTONE_APP_URL} target="_blank" rel="noopener noreferrer" aria-label="Visit Keystone website">
12
+ <KeystoneLogoMinimal className="h-5 w-auto shrink-0" />
13
+ </a>
14
+ <a
15
+ href={KEYSTONE_APP_URL}
16
+ target="_blank"
17
+ rel="noopener noreferrer"
18
+ className="text-xs text-quaternary hover:text-secondary transition-colors"
19
+ >
20
+ Powered by Keystone Universal Login
21
+ </a>
16
22
  </div>
17
23
  );
18
24