@usequeek/theme-kit 0.1.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.
Files changed (120) hide show
  1. package/README.md +37 -0
  2. package/api/collections.ts +93 -0
  3. package/api/orders.ts +23 -0
  4. package/apps/analytics-events.ts +32 -0
  5. package/apps/apps.css +613 -0
  6. package/apps/follow-card.tsx +192 -0
  7. package/apps/gtag.ts +19 -0
  8. package/apps/subscribe-form.tsx +124 -0
  9. package/client.ts +52 -0
  10. package/components/auth/auth-email-login-step.tsx +46 -0
  11. package/components/auth/auth-email-otp-step.tsx +54 -0
  12. package/components/auth/auth-email-otp-verify-step.tsx +82 -0
  13. package/components/auth/auth-email-register-step.tsx +59 -0
  14. package/components/auth/auth-flow.tsx +26 -0
  15. package/components/auth/auth-signup-step.tsx +53 -0
  16. package/components/blog/category-filter.tsx +92 -0
  17. package/components/blog/index.ts +12 -0
  18. package/components/blog/pagination.tsx +73 -0
  19. package/components/blog/post-categories.tsx +42 -0
  20. package/components/blog/post-meta.tsx +49 -0
  21. package/components/blog/related-posts.tsx +54 -0
  22. package/components/blog/share-buttons.tsx +89 -0
  23. package/components/cart/cart-panel-controller.tsx +67 -0
  24. package/components/cart/default-cart-shell.tsx +207 -0
  25. package/components/checkout/checkout-contact.tsx +84 -0
  26. package/components/checkout/default-checkout-shell.tsx +177 -0
  27. package/components/checkout/policy-links.tsx +98 -0
  28. package/components/image.tsx +149 -0
  29. package/components/markdown.tsx +54 -0
  30. package/components/product-media-frame.tsx +56 -0
  31. package/components/variant-picker.css +84 -0
  32. package/components/variant-picker.tsx +107 -0
  33. package/design-token-preview.tsx +81 -0
  34. package/edit-mode-active.tsx +324 -0
  35. package/edit-mode.tsx +80 -0
  36. package/hooks/use-address-autocomplete.ts +62 -0
  37. package/hooks/use-auth-flow.tsx +346 -0
  38. package/hooks/use-auth.ts +17 -0
  39. package/hooks/use-cart.ts +14 -0
  40. package/hooks/use-categories.ts +112 -0
  41. package/hooks/use-collection-params.ts +67 -0
  42. package/hooks/use-href.ts +15 -0
  43. package/hooks/use-posts.ts +75 -0
  44. package/hooks/use-product-detail.ts +69 -0
  45. package/hooks/use-product-questions.ts +77 -0
  46. package/hooks/use-product-sections.ts +165 -0
  47. package/hooks/use-products.ts +216 -0
  48. package/hooks/use-related-products.ts +56 -0
  49. package/hooks/use-resolve-coordinates.ts +48 -0
  50. package/hooks/use-reviews.ts +167 -0
  51. package/hooks/use-search.ts +55 -0
  52. package/hooks/use-shop-cart.ts +31 -0
  53. package/hooks/use-shop-params.ts +68 -0
  54. package/hooks/use-shop.ts +186 -0
  55. package/hooks/use-variant-selection.ts +98 -0
  56. package/index.ts +49 -0
  57. package/package.json +41 -0
  58. package/page-renderer.tsx +365 -0
  59. package/provider.tsx +91 -0
  60. package/sdk/client.ts +221 -0
  61. package/sdk/error-handler.ts +37 -0
  62. package/sdk/platform.ts +78 -0
  63. package/sdk/queek-client.ts +104 -0
  64. package/sdk/reauth-guard.ts +27 -0
  65. package/sdk/server-store-client.ts +165 -0
  66. package/shared-blocks/button.tsx +41 -0
  67. package/shared-blocks/callout.tsx +35 -0
  68. package/shared-blocks/content-default.tsx +23 -0
  69. package/shared-blocks/core-blocks.css +1792 -0
  70. package/shared-blocks/divider.tsx +17 -0
  71. package/shared-blocks/embed.tsx +40 -0
  72. package/shared-blocks/faq.tsx +44 -0
  73. package/shared-blocks/image.tsx +29 -0
  74. package/shared-blocks/index.ts +24 -0
  75. package/shared-blocks/manifest.ts +76 -0
  76. package/shared-blocks/product-qa.tsx +203 -0
  77. package/shared-blocks/quote.tsx +20 -0
  78. package/shared-blocks/reviews.tsx +436 -0
  79. package/shared-blocks/table.tsx +45 -0
  80. package/shared-blocks/video.tsx +60 -0
  81. package/stores/address-modal-store.ts +15 -0
  82. package/stores/auth-modal-store.ts +17 -0
  83. package/stores/cart-panel-store.ts +17 -0
  84. package/stores/cart-store.ts +172 -0
  85. package/stores/checkout-store.ts +167 -0
  86. package/stores/edit-preview-store.ts +61 -0
  87. package/stores/location-store.ts +41 -0
  88. package/stores/orders-sheet-store.ts +21 -0
  89. package/stores/policy-modal-store.ts +15 -0
  90. package/stores/product-modal-store.ts +18 -0
  91. package/stores/search-modal-store.ts +17 -0
  92. package/stores/toast-store.ts +46 -0
  93. package/stores/user-store.ts +88 -0
  94. package/theme-context.tsx +28 -0
  95. package/theme-variant-resolver.ts +44 -0
  96. package/types/block.ts +292 -0
  97. package/types/cart.ts +21 -0
  98. package/types/media.ts +55 -0
  99. package/types/menu.ts +27 -0
  100. package/types/order.ts +119 -0
  101. package/types/page.ts +86 -0
  102. package/types/product.ts +279 -0
  103. package/types/theme.ts +380 -0
  104. package/types/vendor.ts +243 -0
  105. package/utils/addon-selection.ts +31 -0
  106. package/utils/benefit-icon.tsx +145 -0
  107. package/utils/brand-palette.ts +64 -0
  108. package/utils/brand.ts +518 -0
  109. package/utils/contact-location.ts +44 -0
  110. package/utils/format.ts +64 -0
  111. package/utils/hero-content.ts +35 -0
  112. package/utils/image-srcset.ts +157 -0
  113. package/utils/markdown.tsx +237 -0
  114. package/utils/match-variant.ts +31 -0
  115. package/utils/menu-link.ts +41 -0
  116. package/utils/placeholder-resolver.ts +184 -0
  117. package/utils/product-media.ts +77 -0
  118. package/utils/product-normalizer.ts +323 -0
  119. package/utils/vendor-host.ts +173 -0
  120. package/utils/video-embed.ts +38 -0
@@ -0,0 +1,53 @@
1
+ 'use client';
2
+
3
+ import { useAuthFlow } from '../../hooks/use-auth-flow';
4
+
5
+ export function AuthSignupStep(): JSX.Element {
6
+ const {
7
+ firstName, setFirstName,
8
+ lastName, setLastName,
9
+ submitRegister,
10
+ loading, error,
11
+ goToEmailOtp,
12
+ } = useAuthFlow();
13
+
14
+ return (
15
+ <div className="core-auth__step core-auth__step--signup">
16
+ <h2 className="core-auth__title">Create your account</h2>
17
+ <p className="core-auth__sub">Just a few details to get you started</p>
18
+
19
+ <input
20
+ type="text"
21
+ className="core-input"
22
+ placeholder="First name"
23
+ value={firstName}
24
+ onChange={(e) => setFirstName(e.target.value)}
25
+ autoComplete="given-name"
26
+ autoFocus
27
+ />
28
+ <input
29
+ type="text"
30
+ className="core-input"
31
+ placeholder="Last name (optional)"
32
+ value={lastName}
33
+ onChange={(e) => setLastName(e.target.value)}
34
+ autoComplete="family-name"
35
+ />
36
+ {error ? <p className="core-auth__error">{error}</p> : null}
37
+
38
+ <button
39
+ type="button"
40
+ className="core-submit-btn"
41
+ onClick={() => void submitRegister()}
42
+ onKeyDown={(e) => { if (e.key === 'Enter') void submitRegister(); }}
43
+ disabled={loading || !firstName.trim()}
44
+ >
45
+ {loading ? 'Creating account…' : 'Create account'}
46
+ </button>
47
+
48
+ <button type="button" className="core-auth__link" onClick={goToEmailOtp}>
49
+ ← Use a different email
50
+ </button>
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,92 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import type { BlogCategory } from '../../types/page';
5
+ import { useStorefront } from '../../provider';
6
+
7
+ /**
8
+ * Shared blog category browse bar. Links to `${basePath}/blog?category=slug`.
9
+ * Renders nothing when the vendor has no blog categories.
10
+ *
11
+ * - `variant="pills"` (default): compact pill filter row.
12
+ * - `variant="tabs"`: a full tab bar (used at the top of the blog list) with an
13
+ * "All" tab, one tab per category, an active-tab underline, and a subtle
14
+ * per-tab post count. Scrolls horizontally on overflow instead of wrapping.
15
+ *
16
+ * Themes style via `.core-cat-filter*` / `.core-cat-tabs*` (brand-var driven).
17
+ */
18
+ export function CategoryFilter({
19
+ categories,
20
+ active,
21
+ variant = 'pills',
22
+ className,
23
+ }: {
24
+ categories?: BlogCategory[] | null;
25
+ active?: string;
26
+ variant?: 'pills' | 'tabs';
27
+ className?: string;
28
+ }): JSX.Element | null {
29
+ const { basePath } = useStorefront();
30
+
31
+ if (!categories || categories.length === 0) {
32
+ return null;
33
+ }
34
+
35
+ if (variant === 'tabs') {
36
+ return (
37
+ <nav
38
+ className={`core-cat-tabs${className ? ` ${className}` : ''}`}
39
+ aria-label="Blog categories"
40
+ >
41
+ <Link
42
+ href={`${basePath}/blog`}
43
+ className={`core-cat-tabs__item${!active ? ' is-active' : ''}`}
44
+ aria-current={!active ? 'page' : undefined}
45
+ >
46
+ <span className="core-cat-tabs__label">All</span>
47
+ </Link>
48
+ {categories.map((category) => {
49
+ const isActive = active === category.slug;
50
+ return (
51
+ <Link
52
+ key={category.slug}
53
+ href={`${basePath}/blog?category=${encodeURIComponent(category.slug)}`}
54
+ className={`core-cat-tabs__item${isActive ? ' is-active' : ''}`}
55
+ aria-current={isActive ? 'page' : undefined}
56
+ >
57
+ <span className="core-cat-tabs__label">{category.name}</span>
58
+ {typeof category.posts_count === 'number' ? (
59
+ <span className="core-cat-tabs__count">{category.posts_count}</span>
60
+ ) : null}
61
+ </Link>
62
+ );
63
+ })}
64
+ </nav>
65
+ );
66
+ }
67
+
68
+ return (
69
+ <nav className={`core-cat-filter${className ? ` ${className}` : ''}`} aria-label="Blog categories">
70
+ <Link
71
+ href={`${basePath}/blog`}
72
+ className={`core-cat-filter__item${!active ? ' is-active' : ''}`}
73
+ aria-current={!active ? 'page' : undefined}
74
+ >
75
+ All
76
+ </Link>
77
+ {categories.map((category) => {
78
+ const isActive = active === category.slug;
79
+ return (
80
+ <Link
81
+ key={category.slug}
82
+ href={`${basePath}/blog?category=${encodeURIComponent(category.slug)}`}
83
+ className={`core-cat-filter__item${isActive ? ' is-active' : ''}`}
84
+ aria-current={isActive ? 'page' : undefined}
85
+ >
86
+ {category.name}
87
+ </Link>
88
+ );
89
+ })}
90
+ </nav>
91
+ );
92
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared framework-owned blog components. Themes reuse these across their
3
+ * blog list + post detail pages so reader features (byline, share, related,
4
+ * category filter, pagination) live in one place — themes only place and
5
+ * style them via `.core-*` selectors.
6
+ */
7
+ export { PostMeta } from './post-meta';
8
+ export { ShareButtons } from './share-buttons';
9
+ export { RelatedPosts } from './related-posts';
10
+ export { CategoryFilter } from './category-filter';
11
+ export { PostCategories } from './post-categories';
12
+ export { BlogPagination } from './pagination';
@@ -0,0 +1,73 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import type { Pagination } from '../../types/page';
5
+ import { useStorefront } from '../../provider';
6
+
7
+ /**
8
+ * Shared blog pagination (Prev / page indicator / Next). SSR page-based
9
+ * navigation preserving the active category. Renders nothing when there is
10
+ * only a single page. Themes style via `.core-pagination*` selectors.
11
+ */
12
+ export function BlogPagination({
13
+ pagination,
14
+ category,
15
+ className,
16
+ }: {
17
+ pagination?: Pagination;
18
+ category?: string;
19
+ className?: string;
20
+ }): JSX.Element | null {
21
+ const { basePath } = useStorefront();
22
+
23
+ if (!pagination) {
24
+ return null;
25
+ }
26
+
27
+ const current = pagination.current_page ?? 1;
28
+ const perPage = pagination.per_page ?? 0;
29
+ const total = pagination.total ?? 0;
30
+ const lastPage = perPage > 0 && total > 0 ? Math.ceil(total / perPage) : undefined;
31
+ const hasNext = pagination.has_more ?? (lastPage ? current < lastPage : false);
32
+ const hasPrev = current > 1;
33
+
34
+ if (!hasNext && !hasPrev) {
35
+ return null;
36
+ }
37
+
38
+ const buildHref = (page: number): string => {
39
+ const params = new URLSearchParams();
40
+ if (category) {
41
+ params.set('category', category);
42
+ }
43
+ if (page > 1) {
44
+ params.set('page', String(page));
45
+ }
46
+ const query = params.toString();
47
+ return `${basePath}/blog${query ? `?${query}` : ''}`;
48
+ };
49
+
50
+ return (
51
+ <nav className={`core-pagination${className ? ` ${className}` : ''}`} aria-label="Blog pagination">
52
+ {hasPrev ? (
53
+ <Link href={buildHref(current - 1)} className="core-pagination__link" rel="prev">
54
+ ← Previous
55
+ </Link>
56
+ ) : (
57
+ <span className="core-pagination__link is-disabled" aria-disabled="true">
58
+ ← Previous
59
+ </span>
60
+ )}
61
+ <span className="core-pagination__status">{lastPage ? `Page ${current} of ${lastPage}` : `Page ${current}`}</span>
62
+ {hasNext ? (
63
+ <Link href={buildHref(current + 1)} className="core-pagination__link" rel="next">
64
+ Next →
65
+ </Link>
66
+ ) : (
67
+ <span className="core-pagination__link is-disabled" aria-disabled="true">
68
+ Next →
69
+ </span>
70
+ )}
71
+ </nav>
72
+ );
73
+ }
@@ -0,0 +1,42 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import type { BlogCategory } from '../../types/page';
5
+ import { useStorefront } from '../../provider';
6
+
7
+ /**
8
+ * Renders a post's categories as links to the blog list filtered by category
9
+ * (`${basePath}/blog?category=slug`, exactly like {@link CategoryFilter}).
10
+ * Themes pass their own pill className via `itemClassName`. Renders nothing
11
+ * when the post has no categories — themes keep their own tag fallback around
12
+ * it. Wrapper carries `.core-post-cats` so themes can optionally style spacing.
13
+ */
14
+ export function PostCategories({
15
+ categories,
16
+ itemClassName,
17
+ className,
18
+ }: {
19
+ categories?: BlogCategory[] | null;
20
+ itemClassName?: string;
21
+ className?: string;
22
+ }): JSX.Element | null {
23
+ const { basePath } = useStorefront();
24
+
25
+ if (!categories || categories.length === 0) {
26
+ return null;
27
+ }
28
+
29
+ return (
30
+ <span className={`core-post-cats${className ? ` ${className}` : ''}`}>
31
+ {categories.map((category) => (
32
+ <Link
33
+ key={category.slug}
34
+ href={`${basePath}/blog?category=${encodeURIComponent(category.slug)}`}
35
+ className={itemClassName}
36
+ >
37
+ {category.name}
38
+ </Link>
39
+ ))}
40
+ </span>
41
+ );
42
+ }
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+
3
+ import type { Post } from '../../types/page';
4
+ import { useStorefront } from '../../provider';
5
+ import { Image } from '../image';
6
+
7
+ function formatDate(value: string): string {
8
+ const date = new Date(value);
9
+ if (Number.isNaN(date.getTime())) {
10
+ return '';
11
+ }
12
+ return date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
13
+ }
14
+
15
+ /**
16
+ * Shared post byline: author (avatar + name, falling back to the vendor),
17
+ * published date, and reading time. Themes place it in their post header and
18
+ * style via `.core-post-meta*` selectors (colours inherit brand vars).
19
+ */
20
+ export function PostMeta({ post, className }: { post: Post; className?: string }): JSX.Element {
21
+ const { vendor } = useStorefront();
22
+
23
+ const authorName = post.author?.name ?? vendor.name ?? null;
24
+ const authorAvatar = post.author?.avatar_url ?? vendor.logo ?? null;
25
+ const published = post.published_at ? formatDate(post.published_at) : '';
26
+ const readingTime = post.reading_time && post.reading_time > 0 ? `${post.reading_time} min read` : '';
27
+
28
+ return (
29
+ <div className={`core-post-meta${className ? ` ${className}` : ''}`}>
30
+ {authorName ? (
31
+ <span className="core-post-meta__author">
32
+ <Image
33
+ src={authorAvatar || undefined}
34
+ alt={authorName}
35
+ className="core-post-meta__avatar"
36
+ placeholder={{ label: authorName, width: 64, height: 64, variant: 'square' }}
37
+ />
38
+ <span className="core-post-meta__author-name">{authorName}</span>
39
+ </span>
40
+ ) : null}
41
+ {published ? (
42
+ <time className="core-post-meta__date" dateTime={post.published_at ?? undefined}>
43
+ {published}
44
+ </time>
45
+ ) : null}
46
+ {readingTime ? <span className="core-post-meta__reading">{readingTime}</span> : null}
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import type { Post } from '../../types/page';
5
+ import { useStorefront } from '../../provider';
6
+ import { Image } from '../image';
7
+
8
+ /**
9
+ * Shared "Related posts" grid. Renders nothing when there are no related
10
+ * posts. Themes style via `.core-related*` selectors.
11
+ */
12
+ export function RelatedPosts({
13
+ posts,
14
+ className,
15
+ heading = 'Related posts',
16
+ }: {
17
+ posts?: Post[] | null;
18
+ className?: string;
19
+ heading?: string;
20
+ }): JSX.Element | null {
21
+ const { basePath } = useStorefront();
22
+
23
+ if (!posts || posts.length === 0) {
24
+ return null;
25
+ }
26
+
27
+ return (
28
+ <section className={`core-related${className ? ` ${className}` : ''}`}>
29
+ <h2 className="core-related__heading">{heading}</h2>
30
+ <div className="core-related__grid">
31
+ {posts.map((post) => (
32
+ <Link key={post.id ?? post.slug} href={`${basePath}/blog/${post.slug}`} className="core-related__card">
33
+ <div className="core-related__media">
34
+ <Image
35
+ src={post.cover_image_url || undefined}
36
+ alt={post.title}
37
+ className="core-related__img"
38
+ placeholder={{ label: post.title, width: 600, height: 400, variant: 'card' }}
39
+ />
40
+ </div>
41
+ <div className="core-related__body">
42
+ {post.blog_categories?.[0]?.name ? (
43
+ <span className="core-related__cat">{post.blog_categories[0].name}</span>
44
+ ) : post.tags?.[0] ? (
45
+ <span className="core-related__cat">{post.tags[0]}</span>
46
+ ) : null}
47
+ <h3 className="core-related__title">{post.title}</h3>
48
+ </div>
49
+ </Link>
50
+ ))}
51
+ </div>
52
+ </section>
53
+ );
54
+ }
@@ -0,0 +1,89 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ interface ShareButtonsProps {
6
+ title: string;
7
+ className?: string;
8
+ }
9
+
10
+ /**
11
+ * Shared post share bar: native share (where supported), copy link, and
12
+ * X / Facebook / WhatsApp intents. The URL is read on the client so it works
13
+ * on custom domains and subdomains without server knowledge of the host.
14
+ * Themes style via `.core-share*` selectors.
15
+ */
16
+ export function ShareButtons({ title, className }: ShareButtonsProps): JSX.Element {
17
+ const [url, setUrl] = useState('');
18
+ const [copied, setCopied] = useState(false);
19
+ const [canNativeShare, setCanNativeShare] = useState(false);
20
+
21
+ useEffect(() => {
22
+ setUrl(window.location.href);
23
+ setCanNativeShare(typeof navigator !== 'undefined' && typeof navigator.share === 'function');
24
+ }, []);
25
+
26
+ const encodedUrl = encodeURIComponent(url);
27
+ const encodedTitle = encodeURIComponent(title);
28
+
29
+ const nativeShare = async (): Promise<void> => {
30
+ try {
31
+ await navigator.share({ title, url });
32
+ } catch {
33
+ /* user dismissed — no-op */
34
+ }
35
+ };
36
+
37
+ const copyLink = async (): Promise<void> => {
38
+ try {
39
+ await navigator.clipboard.writeText(url);
40
+ setCopied(true);
41
+ window.setTimeout(() => setCopied(false), 2000);
42
+ } catch {
43
+ /* clipboard unavailable — no-op */
44
+ }
45
+ };
46
+
47
+ return (
48
+ <div className={`core-share${className ? ` ${className}` : ''}`}>
49
+ <span className="core-share__label">Share</span>
50
+ <div className="core-share__actions">
51
+ {canNativeShare ? (
52
+ <button type="button" className="core-share__btn" onClick={nativeShare} aria-label="Share">
53
+ Share
54
+ </button>
55
+ ) : null}
56
+ <a
57
+ className="core-share__btn"
58
+ href={`https://x.com/intent/post?url=${encodedUrl}&text=${encodedTitle}`}
59
+ target="_blank"
60
+ rel="noopener noreferrer"
61
+ aria-label="Share on X"
62
+ >
63
+ X
64
+ </a>
65
+ <a
66
+ className="core-share__btn"
67
+ href={`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`}
68
+ target="_blank"
69
+ rel="noopener noreferrer"
70
+ aria-label="Share on Facebook"
71
+ >
72
+ Facebook
73
+ </a>
74
+ <a
75
+ className="core-share__btn"
76
+ href={`https://wa.me/?text=${encodedTitle}%20${encodedUrl}`}
77
+ target="_blank"
78
+ rel="noopener noreferrer"
79
+ aria-label="Share on WhatsApp"
80
+ >
81
+ WhatsApp
82
+ </a>
83
+ <button type="button" className="core-share__btn" onClick={copyLink} aria-label="Copy link">
84
+ {copied ? 'Copied' : 'Copy link'}
85
+ </button>
86
+ </div>
87
+ </div>
88
+ );
89
+ }
@@ -0,0 +1,67 @@
1
+ 'use client';
2
+
3
+ import { useEffect, type ReactNode } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import type { CartItem } from '../../types/cart';
6
+ import { useStorefront } from '../../provider';
7
+ import { useHref } from '../../hooks/use-href';
8
+ import { useShopCart } from '../../hooks/use-shop-cart';
9
+ import { useCartPanelStore } from '../../stores/cart-panel-store';
10
+
11
+ export interface CartPanelControllerRenderProps {
12
+ isOpen: boolean;
13
+ items: CartItem[];
14
+ total: number;
15
+ currency: string;
16
+ onClose: () => void;
17
+ onCheckout: () => void;
18
+ onViewCart: () => void;
19
+ onBrowseCollections: () => void;
20
+ onSetQuantity: (id: string, shopId: string, quantity: number) => void;
21
+ onRemove: (id: string, shopId: string) => void;
22
+ }
23
+
24
+ export function CartPanelController({
25
+ children,
26
+ }: {
27
+ children: (props: CartPanelControllerRenderProps) => ReactNode;
28
+ }): JSX.Element {
29
+ const { vendor } = useStorefront();
30
+ const href = useHref();
31
+ const router = useRouter();
32
+ const isOpen = useCartPanelStore((state) => state.isOpen);
33
+ const closePanel = useCartPanelStore((state) => state.close);
34
+ const { items, total, setQuantity, removeItem } = useShopCart(vendor.id);
35
+
36
+ useEffect(() => {
37
+ if (!isOpen) return;
38
+ const previousOverflow = document.body.style.overflow;
39
+ const onKeyDown = (event: KeyboardEvent) => {
40
+ if (event.key === 'Escape') closePanel();
41
+ };
42
+ document.body.style.overflow = 'hidden';
43
+ document.addEventListener('keydown', onKeyDown);
44
+ return () => {
45
+ document.body.style.overflow = previousOverflow;
46
+ document.removeEventListener('keydown', onKeyDown);
47
+ };
48
+ }, [isOpen, closePanel]);
49
+
50
+ const navigate = (path: string) => {
51
+ closePanel();
52
+ router.push(href(path));
53
+ };
54
+
55
+ return children({
56
+ isOpen,
57
+ items,
58
+ total,
59
+ currency: vendor.currency ?? 'NGN',
60
+ onClose: closePanel,
61
+ onCheckout: () => navigate('/checkout'),
62
+ onViewCart: () => navigate('/cart'),
63
+ onBrowseCollections: () => navigate('/collections'),
64
+ onSetQuantity: setQuantity,
65
+ onRemove: removeItem,
66
+ }) as JSX.Element;
67
+ }