@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.
- package/README.md +37 -0
- package/api/collections.ts +93 -0
- package/api/orders.ts +23 -0
- package/apps/analytics-events.ts +32 -0
- package/apps/apps.css +613 -0
- package/apps/follow-card.tsx +192 -0
- package/apps/gtag.ts +19 -0
- package/apps/subscribe-form.tsx +124 -0
- package/client.ts +52 -0
- package/components/auth/auth-email-login-step.tsx +46 -0
- package/components/auth/auth-email-otp-step.tsx +54 -0
- package/components/auth/auth-email-otp-verify-step.tsx +82 -0
- package/components/auth/auth-email-register-step.tsx +59 -0
- package/components/auth/auth-flow.tsx +26 -0
- package/components/auth/auth-signup-step.tsx +53 -0
- package/components/blog/category-filter.tsx +92 -0
- package/components/blog/index.ts +12 -0
- package/components/blog/pagination.tsx +73 -0
- package/components/blog/post-categories.tsx +42 -0
- package/components/blog/post-meta.tsx +49 -0
- package/components/blog/related-posts.tsx +54 -0
- package/components/blog/share-buttons.tsx +89 -0
- package/components/cart/cart-panel-controller.tsx +67 -0
- package/components/cart/default-cart-shell.tsx +207 -0
- package/components/checkout/checkout-contact.tsx +84 -0
- package/components/checkout/default-checkout-shell.tsx +177 -0
- package/components/checkout/policy-links.tsx +98 -0
- package/components/image.tsx +149 -0
- package/components/markdown.tsx +54 -0
- package/components/product-media-frame.tsx +56 -0
- package/components/variant-picker.css +84 -0
- package/components/variant-picker.tsx +107 -0
- package/design-token-preview.tsx +81 -0
- package/edit-mode-active.tsx +324 -0
- package/edit-mode.tsx +80 -0
- package/hooks/use-address-autocomplete.ts +62 -0
- package/hooks/use-auth-flow.tsx +346 -0
- package/hooks/use-auth.ts +17 -0
- package/hooks/use-cart.ts +14 -0
- package/hooks/use-categories.ts +112 -0
- package/hooks/use-collection-params.ts +67 -0
- package/hooks/use-href.ts +15 -0
- package/hooks/use-posts.ts +75 -0
- package/hooks/use-product-detail.ts +69 -0
- package/hooks/use-product-questions.ts +77 -0
- package/hooks/use-product-sections.ts +165 -0
- package/hooks/use-products.ts +216 -0
- package/hooks/use-related-products.ts +56 -0
- package/hooks/use-resolve-coordinates.ts +48 -0
- package/hooks/use-reviews.ts +167 -0
- package/hooks/use-search.ts +55 -0
- package/hooks/use-shop-cart.ts +31 -0
- package/hooks/use-shop-params.ts +68 -0
- package/hooks/use-shop.ts +186 -0
- package/hooks/use-variant-selection.ts +98 -0
- package/index.ts +49 -0
- package/package.json +41 -0
- package/page-renderer.tsx +365 -0
- package/provider.tsx +91 -0
- package/sdk/client.ts +221 -0
- package/sdk/error-handler.ts +37 -0
- package/sdk/platform.ts +78 -0
- package/sdk/queek-client.ts +104 -0
- package/sdk/reauth-guard.ts +27 -0
- package/sdk/server-store-client.ts +165 -0
- package/shared-blocks/button.tsx +41 -0
- package/shared-blocks/callout.tsx +35 -0
- package/shared-blocks/content-default.tsx +23 -0
- package/shared-blocks/core-blocks.css +1792 -0
- package/shared-blocks/divider.tsx +17 -0
- package/shared-blocks/embed.tsx +40 -0
- package/shared-blocks/faq.tsx +44 -0
- package/shared-blocks/image.tsx +29 -0
- package/shared-blocks/index.ts +24 -0
- package/shared-blocks/manifest.ts +76 -0
- package/shared-blocks/product-qa.tsx +203 -0
- package/shared-blocks/quote.tsx +20 -0
- package/shared-blocks/reviews.tsx +436 -0
- package/shared-blocks/table.tsx +45 -0
- package/shared-blocks/video.tsx +60 -0
- package/stores/address-modal-store.ts +15 -0
- package/stores/auth-modal-store.ts +17 -0
- package/stores/cart-panel-store.ts +17 -0
- package/stores/cart-store.ts +172 -0
- package/stores/checkout-store.ts +167 -0
- package/stores/edit-preview-store.ts +61 -0
- package/stores/location-store.ts +41 -0
- package/stores/orders-sheet-store.ts +21 -0
- package/stores/policy-modal-store.ts +15 -0
- package/stores/product-modal-store.ts +18 -0
- package/stores/search-modal-store.ts +17 -0
- package/stores/toast-store.ts +46 -0
- package/stores/user-store.ts +88 -0
- package/theme-context.tsx +28 -0
- package/theme-variant-resolver.ts +44 -0
- package/types/block.ts +292 -0
- package/types/cart.ts +21 -0
- package/types/media.ts +55 -0
- package/types/menu.ts +27 -0
- package/types/order.ts +119 -0
- package/types/page.ts +86 -0
- package/types/product.ts +279 -0
- package/types/theme.ts +380 -0
- package/types/vendor.ts +243 -0
- package/utils/addon-selection.ts +31 -0
- package/utils/benefit-icon.tsx +145 -0
- package/utils/brand-palette.ts +64 -0
- package/utils/brand.ts +518 -0
- package/utils/contact-location.ts +44 -0
- package/utils/format.ts +64 -0
- package/utils/hero-content.ts +35 -0
- package/utils/image-srcset.ts +157 -0
- package/utils/markdown.tsx +237 -0
- package/utils/match-variant.ts +31 -0
- package/utils/menu-link.ts +41 -0
- package/utils/placeholder-resolver.ts +184 -0
- package/utils/product-media.ts +77 -0
- package/utils/product-normalizer.ts +323 -0
- package/utils/vendor-host.ts +173 -0
- 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
|
+
}
|