@thelol3882/sapar 0.0.0-stage → 0.3.0
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/LICENSE +21 -0
- package/README.md +380 -2
- package/dist/adapter-Y9yB3-q3.d.ts +14 -0
- package/dist/adapter-fFZPsINL.d.ts +14 -0
- package/dist/chunk-7B66QEMY.js +536 -0
- package/dist/chunk-7B66QEMY.js.map +1 -0
- package/dist/chunk-FM64UDKJ.js +893 -0
- package/dist/chunk-FM64UDKJ.js.map +1 -0
- package/dist/chunk-VC6XLWM3.js +176 -0
- package/dist/chunk-VC6XLWM3.js.map +1 -0
- package/dist/chunk-ZBVQGMR4.js +583 -0
- package/dist/chunk-ZBVQGMR4.js.map +1 -0
- package/dist/cli/index.js +1335 -0
- package/dist/core/index.d.ts +273 -0
- package/dist/core/index.js +71 -0
- package/dist/core/index.js.map +1 -0
- package/dist/expo/index.d.ts +189 -0
- package/dist/expo/index.js +39 -0
- package/dist/expo/index.js.map +1 -0
- package/dist/expo-router/index.d.ts +18 -0
- package/dist/expo-router/index.js +46 -0
- package/dist/expo-router/index.js.map +1 -0
- package/dist/next/index.d.ts +18 -0
- package/dist/next/index.js +49 -0
- package/dist/next/index.js.map +1 -0
- package/dist/presentation-CClsPRT6.d.ts +12 -0
- package/dist/react/index.d.ts +35 -0
- package/dist/react/index.js +29 -0
- package/dist/react/index.js.map +1 -0
- package/dist/rtk/index.d.ts +751 -0
- package/dist/rtk/index.js +114 -0
- package/dist/rtk/index.js.map +1 -0
- package/dist/stepCount-CTYYFWiV.d.ts +57 -0
- package/dist/types-CHD2pB5X.d.ts +192 -0
- package/dist/web/index.d.ts +112 -0
- package/dist/web/index.js +34 -0
- package/dist/web/index.js.map +1 -0
- package/package.json +147 -3
- package/templates/expo/AnnouncementModal.tsx +102 -0
- package/templates/expo/TourCard.tsx +112 -0
- package/templates/expo/WhatsNewButton.tsx +87 -0
- package/templates/expo/WhatsNewPanel.tsx +167 -0
- package/templates/mantine/AnnouncementModal.tsx +66 -0
- package/templates/mantine/TourCard.tsx +84 -0
- package/templates/mantine/WhatsNewButton.tsx +62 -0
- package/templates/mantine/WhatsNewPanel.tsx +129 -0
- package/templates/tailwind/AnnouncementModal.tsx +100 -0
- package/templates/tailwind/TourCard.tsx +69 -0
- package/templates/tailwind/WhatsNewButton.tsx +56 -0
- package/templates/tailwind/WhatsNewPanel.tsx +174 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// After copying, change these imports to '@thelol3882/sapar/...'
|
|
4
|
+
import { Anchor, Button, Group, Image, Modal, Stack, Text } from '@mantine/core';
|
|
5
|
+
import { useAnnouncementInterruption, useWhatsNew } from '../../src/react';
|
|
6
|
+
|
|
7
|
+
export interface AnnouncementModalLabels {
|
|
8
|
+
acknowledge: string;
|
|
9
|
+
takeTour: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEFAULT_LABELS: AnnouncementModalLabels = {
|
|
13
|
+
acknowledge: 'Got it',
|
|
14
|
+
takeTour: 'Take the tour',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function AnnouncementModal({ labels }: { labels?: Partial<AnnouncementModalLabels> }) {
|
|
18
|
+
const text = { ...DEFAULT_LABELS, ...labels };
|
|
19
|
+
const { announcement, dismiss, acknowledge } = useAnnouncementInterruption();
|
|
20
|
+
const { recordClick, startTour } = useWhatsNew();
|
|
21
|
+
|
|
22
|
+
if (!announcement) return null;
|
|
23
|
+
const { tourKey } = announcement;
|
|
24
|
+
|
|
25
|
+
async function takeTour(tourKey: string): Promise<void> {
|
|
26
|
+
await acknowledge();
|
|
27
|
+
await startTour(tourKey);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Modal
|
|
32
|
+
opened
|
|
33
|
+
centered
|
|
34
|
+
withCloseButton={false}
|
|
35
|
+
title={announcement.title}
|
|
36
|
+
onClose={() => void dismiss()}
|
|
37
|
+
>
|
|
38
|
+
<Stack gap="sm">
|
|
39
|
+
{announcement.imageUrl && <Image src={announcement.imageUrl} alt="" radius="md" />}
|
|
40
|
+
<Text size="sm" c="dimmed">
|
|
41
|
+
{announcement.body}
|
|
42
|
+
</Text>
|
|
43
|
+
{announcement.cta && (
|
|
44
|
+
<Anchor
|
|
45
|
+
href={announcement.cta.url}
|
|
46
|
+
target="_blank"
|
|
47
|
+
rel="noreferrer"
|
|
48
|
+
size="sm"
|
|
49
|
+
fw={500}
|
|
50
|
+
onClick={() => recordClick(announcement.key)}
|
|
51
|
+
>
|
|
52
|
+
{announcement.cta.label}
|
|
53
|
+
</Anchor>
|
|
54
|
+
)}
|
|
55
|
+
<Group justify="flex-end" mt="xs">
|
|
56
|
+
{tourKey && (
|
|
57
|
+
<Button variant="subtle" color="gray" onClick={() => void takeTour(tourKey)}>
|
|
58
|
+
{text.takeTour}
|
|
59
|
+
</Button>
|
|
60
|
+
)}
|
|
61
|
+
<Button onClick={() => void acknowledge()}>{text.acknowledge}</Button>
|
|
62
|
+
</Group>
|
|
63
|
+
</Stack>
|
|
64
|
+
</Modal>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// After copying, change these imports to '@thelol3882/sapar/...'
|
|
4
|
+
import { Button, CloseButton, Group, Paper, Text, Title } from '@mantine/core';
|
|
5
|
+
import type { CSSProperties } from 'react';
|
|
6
|
+
import { TourOverlay, TourStep, type TourOverlayProps } from '../../src/web';
|
|
7
|
+
import type { StepAnnouncement, StepCount } from '../../src/react';
|
|
8
|
+
|
|
9
|
+
export interface TourCardLabels {
|
|
10
|
+
next: string;
|
|
11
|
+
back: string;
|
|
12
|
+
skip: string;
|
|
13
|
+
done: string;
|
|
14
|
+
close: string;
|
|
15
|
+
progress: ({ position, total }: StepCount) => string;
|
|
16
|
+
announceStep: ({ position, total, title }: StepAnnouncement) => string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_LABELS: TourCardLabels = {
|
|
20
|
+
next: 'Next',
|
|
21
|
+
back: 'Back',
|
|
22
|
+
skip: 'Skip tour',
|
|
23
|
+
done: 'Done',
|
|
24
|
+
close: 'Close tour',
|
|
25
|
+
progress: ({ position, total }) => `${position} / ${total}`,
|
|
26
|
+
announceStep: ({ position, total, title }) => `Step ${position} of ${total}: ${title}`,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const SPOTLIGHT_STYLE: CSSProperties = {
|
|
30
|
+
zIndex: 'var(--mantine-z-index-overlay)',
|
|
31
|
+
color: 'color-mix(in srgb, var(--mantine-color-black) 50%, transparent)',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const POPOVER_STYLE: CSSProperties = { zIndex: 'calc(var(--mantine-z-index-overlay) + 1)' };
|
|
35
|
+
|
|
36
|
+
export interface TourCardProps extends Pick<TourOverlayProps, 'presentation'> {
|
|
37
|
+
labels?: Partial<TourCardLabels>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function TourCard({ labels, presentation }: TourCardProps) {
|
|
41
|
+
const text = { ...DEFAULT_LABELS, ...labels };
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<TourOverlay spotlightStyle={SPOTLIGHT_STYLE} popoverStyle={POPOVER_STYLE} presentation={presentation}>
|
|
45
|
+
<Paper
|
|
46
|
+
withBorder
|
|
47
|
+
shadow="md"
|
|
48
|
+
radius="md"
|
|
49
|
+
p="md"
|
|
50
|
+
w={320}
|
|
51
|
+
maw="calc(100vw - 2rem)"
|
|
52
|
+
className="mantine-focus-auto"
|
|
53
|
+
renderRoot={(props) => <TourStep.Root {...props} announceStep={text.announceStep} />}
|
|
54
|
+
>
|
|
55
|
+
<Group justify="space-between" mb="xs">
|
|
56
|
+
<Text
|
|
57
|
+
size="xs"
|
|
58
|
+
fw={500}
|
|
59
|
+
c="dimmed"
|
|
60
|
+
renderRoot={(props) => <TourStep.Progress {...props}>{text.progress}</TourStep.Progress>}
|
|
61
|
+
/>
|
|
62
|
+
<TourStep.Close aria-label={text.close} render={({ children: _icon, ...props }) => <CloseButton {...props} />} />
|
|
63
|
+
</Group>
|
|
64
|
+
<Title order={5} renderRoot={(props) => <TourStep.Title {...props} />} />
|
|
65
|
+
<Text size="sm" c="dimmed" mt={4} renderRoot={(props) => <TourStep.Body {...props} />} />
|
|
66
|
+
<Group justify="space-between" mt="md">
|
|
67
|
+
<TourStep.Skip render={(props) => <Button variant="subtle" color="gray" size="xs" {...props} />}>
|
|
68
|
+
{text.skip}
|
|
69
|
+
</TourStep.Skip>
|
|
70
|
+
<Group gap="xs">
|
|
71
|
+
<TourStep.Back render={(props) => <Button variant="subtle" color="gray" size="xs" {...props} />}>
|
|
72
|
+
{text.back}
|
|
73
|
+
</TourStep.Back>
|
|
74
|
+
<TourStep.Next
|
|
75
|
+
nextLabel={text.next}
|
|
76
|
+
doneLabel={text.done}
|
|
77
|
+
render={(props) => <Button size="xs" {...props} />}
|
|
78
|
+
/>
|
|
79
|
+
</Group>
|
|
80
|
+
</Group>
|
|
81
|
+
</Paper>
|
|
82
|
+
</TourOverlay>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// After copying, change these imports to '@thelol3882/sapar/...'
|
|
4
|
+
import { ActionIcon, Indicator } from '@mantine/core';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import { useWhatsNew } from '../../src/react';
|
|
7
|
+
import { WhatsNewPanel, type WhatsNewPanelProps } from './WhatsNewPanel';
|
|
8
|
+
|
|
9
|
+
const MAX_DISPLAYED_COUNT = 9;
|
|
10
|
+
|
|
11
|
+
export interface WhatsNewButtonProps extends Pick<WhatsNewPanelProps, 'labels' | 'locale' | 'formatDate'> {
|
|
12
|
+
className?: string;
|
|
13
|
+
/** Accessible name; the unread count is appended. */
|
|
14
|
+
label?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function SparkleIcon() {
|
|
18
|
+
return (
|
|
19
|
+
<svg
|
|
20
|
+
viewBox="0 0 24 24"
|
|
21
|
+
fill="none"
|
|
22
|
+
stroke="currentColor"
|
|
23
|
+
strokeWidth="2"
|
|
24
|
+
strokeLinecap="round"
|
|
25
|
+
strokeLinejoin="round"
|
|
26
|
+
width="20"
|
|
27
|
+
height="20"
|
|
28
|
+
aria-hidden="true"
|
|
29
|
+
>
|
|
30
|
+
<path d="M9.9 15.5a2 2 0 0 0-1.4-1.4l-6.1-1.6a.5.5 0 0 1 0-1l6.1-1.6a2 2 0 0 0 1.4-1.4l1.6-6.1a.5.5 0 0 1 1 0l1.6 6.1a2 2 0 0 0 1.4 1.4l6.1 1.6a.5.5 0 0 1 0 1l-6.1 1.6a2 2 0 0 0-1.4 1.4l-1.6 6.1a.5.5 0 0 1-1 0zM20 3v4M22 5h-4M4 17v2M5 18H3" />
|
|
31
|
+
</svg>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function WhatsNewButton({ className, label = "What's new", labels, locale, formatDate }: WhatsNewButtonProps) {
|
|
36
|
+
const [open, setOpen] = useState(false);
|
|
37
|
+
const { unreadCount } = useWhatsNew();
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<Indicator
|
|
42
|
+
label={unreadCount > MAX_DISPLAYED_COUNT ? `${MAX_DISPLAYED_COUNT}+` : unreadCount}
|
|
43
|
+
disabled={unreadCount === 0}
|
|
44
|
+
size={16}
|
|
45
|
+
color="red"
|
|
46
|
+
>
|
|
47
|
+
<ActionIcon
|
|
48
|
+
variant="subtle"
|
|
49
|
+
color="gray"
|
|
50
|
+
size="lg"
|
|
51
|
+
radius="xl"
|
|
52
|
+
className={className}
|
|
53
|
+
aria-label={unreadCount > 0 ? `${label} (${unreadCount})` : label}
|
|
54
|
+
onClick={() => setOpen(true)}
|
|
55
|
+
>
|
|
56
|
+
<SparkleIcon />
|
|
57
|
+
</ActionIcon>
|
|
58
|
+
</Indicator>
|
|
59
|
+
<WhatsNewPanel open={open} onClose={() => setOpen(false)} labels={labels} locale={locale} formatDate={formatDate} />
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// After copying, change these imports to '@thelol3882/sapar/...'
|
|
4
|
+
import { Anchor, Badge, Button, Drawer, Group, Image, ScrollArea, Stack, Text, Title } from '@mantine/core';
|
|
5
|
+
import { useWhatsNewPanel, type WhatsNewPanelItem } from '../../src/react';
|
|
6
|
+
|
|
7
|
+
export interface WhatsNewPanelLabels {
|
|
8
|
+
title: string;
|
|
9
|
+
empty: string;
|
|
10
|
+
close: string;
|
|
11
|
+
takeTour: string;
|
|
12
|
+
new: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const DEFAULT_LABELS: WhatsNewPanelLabels = {
|
|
16
|
+
title: "What's new",
|
|
17
|
+
empty: 'Nothing new yet.',
|
|
18
|
+
close: 'Close',
|
|
19
|
+
takeTour: 'Take the tour',
|
|
20
|
+
new: 'New',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface WhatsNewPanelProps {
|
|
24
|
+
open: boolean;
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
labels?: Partial<WhatsNewPanelLabels>;
|
|
27
|
+
/** BCP 47 tag for the default date format. Defaults to the browser locale. */
|
|
28
|
+
locale?: string;
|
|
29
|
+
/** Replaces the default date format; the template then never uses `Intl`. */
|
|
30
|
+
formatDate?: (isoDate: string) => string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatDateWithIntl(isoDate: string, locale: string | undefined): string {
|
|
34
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date(isoDate));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Entry({
|
|
38
|
+
item,
|
|
39
|
+
labels,
|
|
40
|
+
formatDate,
|
|
41
|
+
onCtaClick,
|
|
42
|
+
onTakeTour,
|
|
43
|
+
}: {
|
|
44
|
+
item: WhatsNewPanelItem;
|
|
45
|
+
labels: WhatsNewPanelLabels;
|
|
46
|
+
formatDate: (isoDate: string) => string;
|
|
47
|
+
onCtaClick: (key: string) => void;
|
|
48
|
+
onTakeTour: (tourKey: string) => void;
|
|
49
|
+
}) {
|
|
50
|
+
const { tourKey } = item;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<Stack component="li" gap="xs">
|
|
54
|
+
<Group gap="xs">
|
|
55
|
+
<Title order={4}>{item.title}</Title>
|
|
56
|
+
{item.isNew && <Badge size="sm">{labels.new}</Badge>}
|
|
57
|
+
</Group>
|
|
58
|
+
{item.publishedAt && (
|
|
59
|
+
<Text component="time" dateTime={item.publishedAt} size="xs" c="dimmed">
|
|
60
|
+
{formatDate(item.publishedAt)}
|
|
61
|
+
</Text>
|
|
62
|
+
)}
|
|
63
|
+
{item.imageUrl && <Image src={item.imageUrl} alt="" loading="lazy" radius="md" />}
|
|
64
|
+
<Text size="sm" c="dimmed">
|
|
65
|
+
{item.body}
|
|
66
|
+
</Text>
|
|
67
|
+
<Group gap="md">
|
|
68
|
+
{tourKey && (
|
|
69
|
+
<Button size="xs" onClick={() => onTakeTour(tourKey)}>
|
|
70
|
+
{labels.takeTour}
|
|
71
|
+
</Button>
|
|
72
|
+
)}
|
|
73
|
+
{item.cta && (
|
|
74
|
+
<Anchor
|
|
75
|
+
href={item.cta.url}
|
|
76
|
+
target="_blank"
|
|
77
|
+
rel="noreferrer"
|
|
78
|
+
size="sm"
|
|
79
|
+
fw={500}
|
|
80
|
+
onClick={() => onCtaClick(item.key)}
|
|
81
|
+
>
|
|
82
|
+
{item.cta.label}
|
|
83
|
+
</Anchor>
|
|
84
|
+
)}
|
|
85
|
+
</Group>
|
|
86
|
+
</Stack>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function WhatsNewPanel({ open, onClose, labels, locale, formatDate }: WhatsNewPanelProps) {
|
|
91
|
+
const text = { ...DEFAULT_LABELS, ...labels };
|
|
92
|
+
const { items, recordClick, startTour } = useWhatsNewPanel(open);
|
|
93
|
+
const format = formatDate ?? ((isoDate: string) => formatDateWithIntl(isoDate, locale));
|
|
94
|
+
|
|
95
|
+
function takeTour(tourKey: string): void {
|
|
96
|
+
onClose();
|
|
97
|
+
void startTour(tourKey);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Drawer
|
|
102
|
+
opened={open}
|
|
103
|
+
onClose={onClose}
|
|
104
|
+
position="right"
|
|
105
|
+
title={text.title}
|
|
106
|
+
closeButtonProps={{ 'aria-label': text.close }}
|
|
107
|
+
scrollAreaComponent={ScrollArea.Autosize}
|
|
108
|
+
>
|
|
109
|
+
{items.length === 0 ? (
|
|
110
|
+
<Text size="sm" c="dimmed">
|
|
111
|
+
{text.empty}
|
|
112
|
+
</Text>
|
|
113
|
+
) : (
|
|
114
|
+
<Stack component="ul" gap="xl" p={0} m={0} style={{ listStyle: 'none' }}>
|
|
115
|
+
{items.map((item) => (
|
|
116
|
+
<Entry
|
|
117
|
+
key={item.key}
|
|
118
|
+
item={item}
|
|
119
|
+
labels={text}
|
|
120
|
+
formatDate={format}
|
|
121
|
+
onCtaClick={recordClick}
|
|
122
|
+
onTakeTour={takeTour}
|
|
123
|
+
/>
|
|
124
|
+
))}
|
|
125
|
+
</Stack>
|
|
126
|
+
)}
|
|
127
|
+
</Drawer>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// After copying, change these imports to '@thelol3882/sapar/...'
|
|
4
|
+
import { useEffect, useRef, type KeyboardEvent } from 'react';
|
|
5
|
+
import { useAnnouncementInterruption, useWhatsNew } from '../../src/react';
|
|
6
|
+
import { BodyPortal } from '../../src/web';
|
|
7
|
+
|
|
8
|
+
export interface AnnouncementModalLabels {
|
|
9
|
+
acknowledge: string;
|
|
10
|
+
takeTour: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const DEFAULT_LABELS: AnnouncementModalLabels = {
|
|
14
|
+
acknowledge: 'Got it',
|
|
15
|
+
takeTour: 'Take the tour',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Replace these with your design tokens.
|
|
19
|
+
const styles = {
|
|
20
|
+
overlay: 'fixed inset-0 z-[900] flex items-center justify-center p-4',
|
|
21
|
+
backdrop: 'absolute inset-0 bg-black/50',
|
|
22
|
+
dialog: 'relative w-full max-w-md space-y-3 rounded-xl bg-white p-6 text-neutral-900 shadow-xl focus:outline-none dark:bg-neutral-900 dark:text-neutral-100',
|
|
23
|
+
image: 'w-full rounded-lg',
|
|
24
|
+
title: 'text-lg font-semibold',
|
|
25
|
+
body: 'text-sm text-neutral-600 dark:text-neutral-300',
|
|
26
|
+
link: 'inline-block text-sm font-medium text-indigo-600 hover:underline dark:text-indigo-400',
|
|
27
|
+
actions: 'flex justify-end gap-2 pt-2',
|
|
28
|
+
secondaryButton: 'rounded-lg px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-800',
|
|
29
|
+
primaryButton: 'rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function AnnouncementModal({ labels }: { labels?: Partial<AnnouncementModalLabels> }) {
|
|
33
|
+
const text = { ...DEFAULT_LABELS, ...labels };
|
|
34
|
+
const { announcement, dismiss, acknowledge } = useAnnouncementInterruption();
|
|
35
|
+
const { recordClick, startTour } = useWhatsNew();
|
|
36
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
37
|
+
const announcementKey = announcement?.key;
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (announcementKey) dialogRef.current?.focus();
|
|
41
|
+
}, [announcementKey]);
|
|
42
|
+
|
|
43
|
+
if (!announcement) return null;
|
|
44
|
+
const { tourKey } = announcement;
|
|
45
|
+
|
|
46
|
+
function handleKeyDown(event: KeyboardEvent): void {
|
|
47
|
+
if (event.key === 'Escape') void dismiss();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function takeTour(tourKey: string): Promise<void> {
|
|
51
|
+
await acknowledge();
|
|
52
|
+
await startTour(tourKey);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<BodyPortal>
|
|
57
|
+
<div className={styles.overlay} onKeyDown={handleKeyDown}>
|
|
58
|
+
<div className={styles.backdrop} aria-hidden="true" onClick={() => void dismiss()} />
|
|
59
|
+
<div
|
|
60
|
+
ref={dialogRef}
|
|
61
|
+
role="dialog"
|
|
62
|
+
aria-modal="true"
|
|
63
|
+
aria-labelledby="announcement-title"
|
|
64
|
+
aria-describedby="announcement-body"
|
|
65
|
+
tabIndex={-1}
|
|
66
|
+
className={styles.dialog}
|
|
67
|
+
>
|
|
68
|
+
{announcement.imageUrl && <img src={announcement.imageUrl} alt="" className={styles.image} />}
|
|
69
|
+
<h2 id="announcement-title" className={styles.title}>
|
|
70
|
+
{announcement.title}
|
|
71
|
+
</h2>
|
|
72
|
+
<p id="announcement-body" className={styles.body}>
|
|
73
|
+
{announcement.body}
|
|
74
|
+
</p>
|
|
75
|
+
{announcement.cta && (
|
|
76
|
+
<a
|
|
77
|
+
href={announcement.cta.url}
|
|
78
|
+
target="_blank"
|
|
79
|
+
rel="noreferrer"
|
|
80
|
+
onClick={() => recordClick(announcement.key)}
|
|
81
|
+
className={styles.link}
|
|
82
|
+
>
|
|
83
|
+
{announcement.cta.label}
|
|
84
|
+
</a>
|
|
85
|
+
)}
|
|
86
|
+
<div className={styles.actions}>
|
|
87
|
+
{tourKey && (
|
|
88
|
+
<button type="button" onClick={() => void takeTour(tourKey)} className={styles.secondaryButton}>
|
|
89
|
+
{text.takeTour}
|
|
90
|
+
</button>
|
|
91
|
+
)}
|
|
92
|
+
<button type="button" onClick={() => void acknowledge()} className={styles.primaryButton}>
|
|
93
|
+
{text.acknowledge}
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</BodyPortal>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// After copying, change these imports to '@thelol3882/sapar/...'
|
|
4
|
+
import { TourOverlay, TourStep, type TourOverlayProps } from '../../src/web';
|
|
5
|
+
import type { StepAnnouncement, StepCount } from '../../src/react';
|
|
6
|
+
|
|
7
|
+
export interface TourCardLabels {
|
|
8
|
+
next: string;
|
|
9
|
+
back: string;
|
|
10
|
+
skip: string;
|
|
11
|
+
done: string;
|
|
12
|
+
close: string;
|
|
13
|
+
progress: ({ position, total }: StepCount) => string;
|
|
14
|
+
announceStep: ({ position, total, title }: StepAnnouncement) => string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_LABELS: TourCardLabels = {
|
|
18
|
+
next: 'Next',
|
|
19
|
+
back: 'Back',
|
|
20
|
+
skip: 'Skip tour',
|
|
21
|
+
done: 'Done',
|
|
22
|
+
close: 'Close tour',
|
|
23
|
+
progress: ({ position, total }) => `${position} / ${total}`,
|
|
24
|
+
announceStep: ({ position, total, title }) => `Step ${position} of ${total}: ${title}`,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Replace these with your design tokens.
|
|
28
|
+
const styles = {
|
|
29
|
+
spotlight: 'z-[1000] text-black/50',
|
|
30
|
+
popover: 'z-[1001]',
|
|
31
|
+
card: 'w-80 max-w-[calc(100vw-2rem)] rounded-xl border border-neutral-200 bg-white p-4 text-neutral-900 shadow-xl outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:focus-visible:ring-offset-neutral-900',
|
|
32
|
+
header: 'flex items-center justify-between',
|
|
33
|
+
progress: 'text-xs font-medium text-neutral-500 dark:text-neutral-400',
|
|
34
|
+
close: '-mr-1 rounded-md px-2 text-lg leading-none text-neutral-500 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-indigo-500 dark:hover:bg-neutral-800',
|
|
35
|
+
title: 'mt-2 text-base font-semibold',
|
|
36
|
+
body: 'mt-1 text-sm text-neutral-600 dark:text-neutral-300',
|
|
37
|
+
footer: 'mt-4 flex items-center justify-between gap-2',
|
|
38
|
+
actions: 'flex items-center gap-2',
|
|
39
|
+
secondaryButton: 'rounded-lg px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 dark:text-neutral-300 dark:hover:bg-neutral-800',
|
|
40
|
+
primaryButton: 'rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface TourCardProps extends Pick<TourOverlayProps, 'presentation'> {
|
|
44
|
+
labels?: Partial<TourCardLabels>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function TourCard({ labels, presentation }: TourCardProps) {
|
|
48
|
+
const text = { ...DEFAULT_LABELS, ...labels };
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<TourOverlay spotlightClassName={styles.spotlight} popoverClassName={styles.popover} presentation={presentation}>
|
|
52
|
+
<TourStep.Root announceStep={text.announceStep} className={styles.card}>
|
|
53
|
+
<div className={styles.header}>
|
|
54
|
+
<TourStep.Progress className={styles.progress}>{text.progress}</TourStep.Progress>
|
|
55
|
+
<TourStep.Close aria-label={text.close} className={styles.close} />
|
|
56
|
+
</div>
|
|
57
|
+
<TourStep.Title className={styles.title} />
|
|
58
|
+
<TourStep.Body className={styles.body} />
|
|
59
|
+
<div className={styles.footer}>
|
|
60
|
+
<TourStep.Skip className={styles.secondaryButton}>{text.skip}</TourStep.Skip>
|
|
61
|
+
<div className={styles.actions}>
|
|
62
|
+
<TourStep.Back className={styles.secondaryButton}>{text.back}</TourStep.Back>
|
|
63
|
+
<TourStep.Next nextLabel={text.next} doneLabel={text.done} className={styles.primaryButton} />
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</TourStep.Root>
|
|
67
|
+
</TourOverlay>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// After copying, change these imports to '@thelol3882/sapar/...'
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
import { useWhatsNew } from '../../src/react';
|
|
6
|
+
import { WhatsNewPanel, type WhatsNewPanelProps } from './WhatsNewPanel';
|
|
7
|
+
|
|
8
|
+
// Replace these with your design tokens.
|
|
9
|
+
const styles = {
|
|
10
|
+
button: 'relative rounded-full p-2 text-neutral-600 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-indigo-500 dark:text-neutral-300 dark:hover:bg-neutral-800',
|
|
11
|
+
icon: 'size-5',
|
|
12
|
+
badge: 'absolute -right-0.5 -top-0.5 flex min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-semibold leading-4 text-white',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface WhatsNewButtonProps extends Pick<WhatsNewPanelProps, 'labels' | 'locale' | 'formatDate'> {
|
|
16
|
+
className?: string;
|
|
17
|
+
/** Accessible name; the unread count is appended. */
|
|
18
|
+
label?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function SparkleIcon() {
|
|
22
|
+
return (
|
|
23
|
+
<svg
|
|
24
|
+
viewBox="0 0 24 24"
|
|
25
|
+
fill="none"
|
|
26
|
+
stroke="currentColor"
|
|
27
|
+
strokeWidth="2"
|
|
28
|
+
strokeLinecap="round"
|
|
29
|
+
strokeLinejoin="round"
|
|
30
|
+
className={styles.icon}
|
|
31
|
+
aria-hidden="true"
|
|
32
|
+
>
|
|
33
|
+
<path d="M9.9 15.5a2 2 0 0 0-1.4-1.4l-6.1-1.6a.5.5 0 0 1 0-1l6.1-1.6a2 2 0 0 0 1.4-1.4l1.6-6.1a.5.5 0 0 1 1 0l1.6 6.1a2 2 0 0 0 1.4 1.4l6.1 1.6a.5.5 0 0 1 0 1l-6.1 1.6a2 2 0 0 0-1.4 1.4l-1.6 6.1a.5.5 0 0 1-1 0zM20 3v4M22 5h-4M4 17v2M5 18H3" />
|
|
34
|
+
</svg>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function WhatsNewButton({ className, label = "What's new", labels, locale, formatDate }: WhatsNewButtonProps) {
|
|
39
|
+
const [open, setOpen] = useState(false);
|
|
40
|
+
const { unreadCount } = useWhatsNew();
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
aria-label={unreadCount > 0 ? `${label} (${unreadCount})` : label}
|
|
47
|
+
onClick={() => setOpen(true)}
|
|
48
|
+
className={className ?? styles.button}
|
|
49
|
+
>
|
|
50
|
+
<SparkleIcon />
|
|
51
|
+
{unreadCount > 0 && <span className={styles.badge}>{unreadCount}</span>}
|
|
52
|
+
</button>
|
|
53
|
+
<WhatsNewPanel open={open} onClose={() => setOpen(false)} labels={labels} locale={locale} formatDate={formatDate} />
|
|
54
|
+
</>
|
|
55
|
+
);
|
|
56
|
+
}
|