@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,17 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { DividerBlockData } from '../types/block';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Framework-owned divider block. Themes style via `.core-block-divider`
|
|
7
|
+
* and the modifier classes in their theme.css.
|
|
8
|
+
*/
|
|
9
|
+
export function CoreDividerBlock({ style }: DividerBlockData): JSX.Element {
|
|
10
|
+
const modifier = style === 'space' ? 'core-block-divider--space' : 'core-block-divider--line';
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className={`core-block-divider ${modifier}`} role="separator">
|
|
14
|
+
{style !== 'space' ? <hr className="core-block-divider__line" /> : null}
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { EmbedBlockData } from '../types/block';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Framework-owned embed block. Renders an iframe for any external URL
|
|
7
|
+
* (Instagram, Spotify, Twitter, custom). Themes style via `.core-block-embed`.
|
|
8
|
+
*/
|
|
9
|
+
export function CoreEmbedBlock({ url }: EmbedBlockData): JSX.Element {
|
|
10
|
+
if (!url) {
|
|
11
|
+
return (
|
|
12
|
+
<section className="core-block core-block-embed">
|
|
13
|
+
<div className="core-block-embed__wrap core-block-embed__wrap--empty">
|
|
14
|
+
<div className="core-block-embed__placeholder">
|
|
15
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
16
|
+
<polyline points="16 18 22 12 16 6" />
|
|
17
|
+
<polyline points="8 6 2 12 8 18" />
|
|
18
|
+
</svg>
|
|
19
|
+
<span>Paste an Instagram, Spotify, or iframe URL</span>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</section>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<section className="core-block core-block-embed">
|
|
28
|
+
<div className="core-block-embed__wrap">
|
|
29
|
+
<iframe
|
|
30
|
+
className="core-block-embed__frame"
|
|
31
|
+
src={url}
|
|
32
|
+
title="Embedded content"
|
|
33
|
+
allowFullScreen
|
|
34
|
+
loading="lazy"
|
|
35
|
+
referrerPolicy="no-referrer-when-downgrade"
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
</section>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { FaqBlockData } from '../types/block';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Framework-owned FAQ block. Renders an accessible, SSR-friendly accordion
|
|
7
|
+
* using native <details>/<summary> — works with zero JS. Themes style via
|
|
8
|
+
* `.core-block-faq` + sub-elements.
|
|
9
|
+
*/
|
|
10
|
+
export function CoreFaqBlock({ heading, items }: FaqBlockData): JSX.Element | null {
|
|
11
|
+
const safeItems = Array.isArray(items) ? items.filter((item) => item && item.question) : [];
|
|
12
|
+
|
|
13
|
+
if (safeItems.length === 0) return null;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<section className="core-block core-block-faq">
|
|
17
|
+
{heading ? <h2 className="core-block-faq__heading">{heading}</h2> : null}
|
|
18
|
+
<div className="core-block-faq__list">
|
|
19
|
+
{safeItems.map((item, index) => (
|
|
20
|
+
<details key={index} className="core-block-faq__item">
|
|
21
|
+
<summary className="core-block-faq__question">
|
|
22
|
+
<span>{item.question}</span>
|
|
23
|
+
<svg
|
|
24
|
+
className="core-block-faq__chevron"
|
|
25
|
+
width="18"
|
|
26
|
+
height="18"
|
|
27
|
+
viewBox="0 0 24 24"
|
|
28
|
+
fill="none"
|
|
29
|
+
stroke="currentColor"
|
|
30
|
+
strokeWidth="2"
|
|
31
|
+
strokeLinecap="round"
|
|
32
|
+
strokeLinejoin="round"
|
|
33
|
+
aria-hidden="true"
|
|
34
|
+
>
|
|
35
|
+
<polyline points="6 9 12 15 18 9" />
|
|
36
|
+
</svg>
|
|
37
|
+
</summary>
|
|
38
|
+
<div className="core-block-faq__answer">{item.answer}</div>
|
|
39
|
+
</details>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
</section>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ImageBlockData } from '../types/block';
|
|
4
|
+
import { Image } from '../components/image';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Framework-owned image block. Wraps the core <Image> (with Smart Placeholder
|
|
8
|
+
* support) and an optional caption. Themes style via `.core-block-image`.
|
|
9
|
+
*/
|
|
10
|
+
export function CoreImageBlock({ url, alt, caption }: ImageBlockData): JSX.Element {
|
|
11
|
+
return (
|
|
12
|
+
<section className="core-block core-block-image">
|
|
13
|
+
<figure className="core-block-image__figure">
|
|
14
|
+
<Image
|
|
15
|
+
src={url || undefined}
|
|
16
|
+
alt={alt ?? ''}
|
|
17
|
+
className="core-block-image__img"
|
|
18
|
+
placeholder={{
|
|
19
|
+
label: alt ?? caption ?? undefined,
|
|
20
|
+
width: 1200,
|
|
21
|
+
height: 800,
|
|
22
|
+
variant: 'photo',
|
|
23
|
+
}}
|
|
24
|
+
/>
|
|
25
|
+
{caption ? <figcaption className="core-block-image__caption">{caption}</figcaption> : null}
|
|
26
|
+
</figure>
|
|
27
|
+
</section>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-owned blocks. Themes do NOT implement these — the React logic
|
|
3
|
+
* lives here, themes only style them via `.core-block-*` scoped selectors
|
|
4
|
+
* in their theme.css.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { CoreDividerBlock } from './divider';
|
|
8
|
+
export { CoreCalloutBlock } from './callout';
|
|
9
|
+
export { CoreQuoteBlock } from './quote';
|
|
10
|
+
export { CoreEmbedBlock } from './embed';
|
|
11
|
+
export { CoreVideoBlock } from './video';
|
|
12
|
+
export { CoreTableBlock } from './table';
|
|
13
|
+
export { CoreButtonBlock } from './button';
|
|
14
|
+
export { CoreImageBlock } from './image';
|
|
15
|
+
export { CoreContentDefaultBlock } from './content-default';
|
|
16
|
+
export { CoreReviewsBlock } from './reviews';
|
|
17
|
+
export { CoreFaqBlock } from './faq';
|
|
18
|
+
export { CoreProductQaBlock } from './product-qa';
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
FRAMEWORK_BLOCK_MANIFEST,
|
|
22
|
+
FRAMEWORK_OWNED_TYPES,
|
|
23
|
+
CONTENT_DEFAULT_IS_FRAMEWORK_OWNED,
|
|
24
|
+
} from './manifest';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ThemeManifestVariant } from '../types/theme';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Framework-owned block manifest. These variants are available in EVERY
|
|
5
|
+
* theme's Section Library drawer — themes don't implement them and can't
|
|
6
|
+
* override their React logic, only their CSS styling via `.core-block-*`
|
|
7
|
+
* scoped selectors.
|
|
8
|
+
*
|
|
9
|
+
* Keep in sync with FRAMEWORK_OWNED_TYPES in page-renderer.tsx and the
|
|
10
|
+
* shared-blocks index export.
|
|
11
|
+
*/
|
|
12
|
+
export const FRAMEWORK_BLOCK_MANIFEST: Record<string, ThemeManifestVariant[]> = {
|
|
13
|
+
divider: [
|
|
14
|
+
{ id: 'default', label: 'Divider', default: true, purpose: 'Visual separator between sections' },
|
|
15
|
+
],
|
|
16
|
+
callout: [
|
|
17
|
+
{ id: 'default', label: 'Callout', default: true, purpose: 'Highlighted info/warn/success/note box for article emphasis' },
|
|
18
|
+
],
|
|
19
|
+
quote: [
|
|
20
|
+
{ id: 'default', label: 'Quote', default: true, purpose: 'Pull-quote with optional attribution' },
|
|
21
|
+
],
|
|
22
|
+
embed: [
|
|
23
|
+
{ id: 'default', label: 'Embed', default: true, purpose: 'Instagram, Spotify, Twitter, or any iframe URL' },
|
|
24
|
+
],
|
|
25
|
+
video: [
|
|
26
|
+
{ id: 'default', label: 'Video', default: true, purpose: 'Embed a YouTube, Vimeo, or self-hosted video' },
|
|
27
|
+
],
|
|
28
|
+
table: [
|
|
29
|
+
{ id: 'default', label: 'Table', default: true, purpose: 'Pricing, specs, or comparison tables' },
|
|
30
|
+
],
|
|
31
|
+
button: [
|
|
32
|
+
{ id: 'default', label: 'Button', default: true, purpose: 'Standalone call-to-action link' },
|
|
33
|
+
],
|
|
34
|
+
image: [
|
|
35
|
+
{ id: 'default', label: 'Image', default: true, purpose: 'A single standalone image with caption' },
|
|
36
|
+
],
|
|
37
|
+
reviews: [
|
|
38
|
+
{ id: 'grid', label: 'Grid', default: true, purpose: 'Verified customer reviews in a responsive grid' },
|
|
39
|
+
{ id: 'carousel', label: 'Carousel', purpose: 'Verified customer reviews in a horizontal scroller' },
|
|
40
|
+
{ id: 'wall', label: 'Wall', purpose: 'Masonry-style wall of verified customer reviews' },
|
|
41
|
+
{ id: 'compact', label: 'Compact', purpose: 'Compact list with rating breakdown header' },
|
|
42
|
+
],
|
|
43
|
+
faq: [
|
|
44
|
+
{ id: 'default', label: 'FAQ', default: true, purpose: 'Accordion of question/answer pairs' },
|
|
45
|
+
],
|
|
46
|
+
product_qa: [
|
|
47
|
+
{ id: 'default', label: 'Product Q&A', default: true, purpose: 'Customer questions and vendor answers for this product' },
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Block types that are fully framework-owned (React logic + routing).
|
|
53
|
+
* Themes can style these via `.core-block-*` CSS but do NOT implement the
|
|
54
|
+
* React components. page-renderer.tsx routes these to core directly.
|
|
55
|
+
*/
|
|
56
|
+
export const FRAMEWORK_OWNED_TYPES = new Set<string>([
|
|
57
|
+
'divider',
|
|
58
|
+
'callout',
|
|
59
|
+
'quote',
|
|
60
|
+
'embed',
|
|
61
|
+
'video',
|
|
62
|
+
'table',
|
|
63
|
+
'button',
|
|
64
|
+
'image',
|
|
65
|
+
'reviews',
|
|
66
|
+
'faq',
|
|
67
|
+
'product_qa',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `content/default` is a special case — the default content variant is
|
|
72
|
+
* framework-owned (plain markdown rendering), but content variants with
|
|
73
|
+
* specific IDs (promo, brand-story, testimonials, marquee, features-columns,
|
|
74
|
+
* before-after) stay theme-owned. page-renderer.tsx handles this split.
|
|
75
|
+
*/
|
|
76
|
+
export const CONTENT_DEFAULT_IS_FRAMEWORK_OWNED = true;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { ProductQaBlockData, ProductQuestionItem } from '../types/block';
|
|
5
|
+
import { Image } from '../components/image';
|
|
6
|
+
import { useProductQuestions } from '../hooks/use-product-questions';
|
|
7
|
+
import { useAuth } from '../hooks/use-auth';
|
|
8
|
+
import { useStorefront } from '../provider';
|
|
9
|
+
import { getQueekClient } from '../sdk/queek-client';
|
|
10
|
+
|
|
11
|
+
const MAX_QUESTION_LENGTH = 1000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Framework-owned Product Q&A block. Renders answered customer questions
|
|
15
|
+
* fetched from `GET /client/store/products/{slug}/questions` and lets
|
|
16
|
+
* authenticated customers ask new ones via `POST` to the same path. Themes
|
|
17
|
+
* style via `.core-block-product-qa` and its `__` sub-elements.
|
|
18
|
+
*/
|
|
19
|
+
export function CoreProductQaBlock(props: ProductQaBlockData): JSX.Element {
|
|
20
|
+
const { questions, isLoading } = useProductQuestions(props);
|
|
21
|
+
const title = props.title;
|
|
22
|
+
const hasQuestions = questions.length > 0;
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<section className="core-block core-block-product-qa" data-loading={isLoading ? 'true' : undefined}>
|
|
26
|
+
<header className="core-block-product-qa__header">
|
|
27
|
+
{title ? <h2 className="core-block-product-qa__title">{title}</h2> : null}
|
|
28
|
+
<AskQuestionPanel productSlug={props.product_slug} />
|
|
29
|
+
</header>
|
|
30
|
+
|
|
31
|
+
{!hasQuestions && !isLoading && (
|
|
32
|
+
<p className="core-block-product-qa__empty">No questions yet — be the first to ask.</p>
|
|
33
|
+
)}
|
|
34
|
+
|
|
35
|
+
{hasQuestions && (
|
|
36
|
+
<div className="core-block-product-qa__list">
|
|
37
|
+
{questions.map((question) => (
|
|
38
|
+
<QuestionCard key={question.id} question={question} />
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
)}
|
|
42
|
+
</section>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function AskQuestionPanel({ productSlug }: { productSlug: string }): JSX.Element {
|
|
47
|
+
const { vendor } = useStorefront();
|
|
48
|
+
const { user, openAuthModal } = useAuth();
|
|
49
|
+
const [open, setOpen] = useState(false);
|
|
50
|
+
const [question, setQuestion] = useState('');
|
|
51
|
+
const [submitting, setSubmitting] = useState(false);
|
|
52
|
+
const [error, setError] = useState<string | null>(null);
|
|
53
|
+
const [submitted, setSubmitted] = useState(false);
|
|
54
|
+
|
|
55
|
+
const handleOpen = (): void => {
|
|
56
|
+
if (!user) {
|
|
57
|
+
openAuthModal('login');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
setSubmitted(false);
|
|
61
|
+
setOpen(true);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const handleCancel = (): void => {
|
|
65
|
+
setOpen(false);
|
|
66
|
+
setError(null);
|
|
67
|
+
setQuestion('');
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleSubmit = async (): Promise<void> => {
|
|
71
|
+
const trimmed = question.trim();
|
|
72
|
+
if (!trimmed) {
|
|
73
|
+
setError('Please write a question.');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setSubmitting(true);
|
|
78
|
+
setError(null);
|
|
79
|
+
try {
|
|
80
|
+
const client = getQueekClient(vendor.slug ?? undefined);
|
|
81
|
+
await client.post(`/client/store/products/${productSlug}/questions`, { question: trimmed });
|
|
82
|
+
setOpen(false);
|
|
83
|
+
setQuestion('');
|
|
84
|
+
setSubmitted(true);
|
|
85
|
+
} catch {
|
|
86
|
+
setError('Could not submit your question. Please try again.');
|
|
87
|
+
} finally {
|
|
88
|
+
setSubmitting(false);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
if (submitted) {
|
|
93
|
+
return (
|
|
94
|
+
<div className="core-block-product-qa__ask">
|
|
95
|
+
<p className="core-block-product-qa__thanks">
|
|
96
|
+
Thanks — we'll notify you when the vendor answers.
|
|
97
|
+
</p>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!open) {
|
|
103
|
+
return (
|
|
104
|
+
<div className="core-block-product-qa__ask">
|
|
105
|
+
<button type="button" className="core-block-product-qa__ask-btn" onClick={handleOpen}>
|
|
106
|
+
Ask a question
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="core-block-product-qa__ask core-block-product-qa__ask--open">
|
|
114
|
+
<textarea
|
|
115
|
+
className="core-block-product-qa__textarea"
|
|
116
|
+
rows={3}
|
|
117
|
+
maxLength={MAX_QUESTION_LENGTH}
|
|
118
|
+
placeholder="What would you like to know about this product?"
|
|
119
|
+
value={question}
|
|
120
|
+
onChange={(e) => setQuestion(e.target.value)}
|
|
121
|
+
disabled={submitting}
|
|
122
|
+
/>
|
|
123
|
+
{error ? <p className="core-block-product-qa__error">{error}</p> : null}
|
|
124
|
+
<div className="core-block-product-qa__ask-actions">
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
className="core-block-product-qa__cancel-btn"
|
|
128
|
+
onClick={handleCancel}
|
|
129
|
+
disabled={submitting}
|
|
130
|
+
>
|
|
131
|
+
Cancel
|
|
132
|
+
</button>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
className="core-block-product-qa__submit-btn"
|
|
136
|
+
onClick={() => void handleSubmit()}
|
|
137
|
+
disabled={submitting || question.trim().length === 0}
|
|
138
|
+
>
|
|
139
|
+
{submitting ? 'Submitting…' : 'Submit question'}
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function QuestionCard({ question }: { question: ProductQuestionItem }): JSX.Element {
|
|
147
|
+
return (
|
|
148
|
+
<article className="core-block-product-qa__card">
|
|
149
|
+
<header className="core-block-product-qa__card-head">
|
|
150
|
+
<div className="core-block-product-qa__avatar">
|
|
151
|
+
{question.user.avatar ? (
|
|
152
|
+
<Image
|
|
153
|
+
src={question.user.avatar}
|
|
154
|
+
alt={question.user.name}
|
|
155
|
+
className="core-block-product-qa__avatar-img"
|
|
156
|
+
/>
|
|
157
|
+
) : (
|
|
158
|
+
<span className="core-block-product-qa__avatar-initials" aria-hidden="true">
|
|
159
|
+
{initialsFor(question.user.name)}
|
|
160
|
+
</span>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
<div className="core-block-product-qa__card-meta">
|
|
164
|
+
<p className="core-block-product-qa__author">{question.user.name}</p>
|
|
165
|
+
<p className="core-block-product-qa__date">{formatDate(question.created_at)}</p>
|
|
166
|
+
</div>
|
|
167
|
+
</header>
|
|
168
|
+
|
|
169
|
+
<p className="core-block-product-qa__question">{question.question}</p>
|
|
170
|
+
|
|
171
|
+
{question.answer ? (
|
|
172
|
+
<div className="core-block-product-qa__answer">
|
|
173
|
+
<p className="core-block-product-qa__answer-label">
|
|
174
|
+
Answered{question.answered_at ? ` · ${formatDate(question.answered_at)}` : ''}
|
|
175
|
+
</p>
|
|
176
|
+
<p className="core-block-product-qa__answer-body">{question.answer}</p>
|
|
177
|
+
</div>
|
|
178
|
+
) : null}
|
|
179
|
+
</article>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function initialsFor(name: string): string {
|
|
184
|
+
return name
|
|
185
|
+
.split(/\s+/)
|
|
186
|
+
.map((part) => part.charAt(0))
|
|
187
|
+
.filter(Boolean)
|
|
188
|
+
.slice(0, 2)
|
|
189
|
+
.join('')
|
|
190
|
+
.toUpperCase();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function formatDate(iso: string): string {
|
|
194
|
+
try {
|
|
195
|
+
return new Date(iso).toLocaleDateString(undefined, {
|
|
196
|
+
month: 'short',
|
|
197
|
+
day: 'numeric',
|
|
198
|
+
year: 'numeric',
|
|
199
|
+
});
|
|
200
|
+
} catch {
|
|
201
|
+
return '';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { QuoteBlockData } from '../types/block';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Framework-owned pull-quote block. Renders a blockquote with an optional
|
|
7
|
+
* attribution line. Themes style via `.core-block-quote` scoped selectors.
|
|
8
|
+
*/
|
|
9
|
+
export function CoreQuoteBlock({ markdown, text, cite }: QuoteBlockData): JSX.Element {
|
|
10
|
+
const body = (markdown ?? text ?? '').trim();
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<section className="core-block core-block-quote">
|
|
14
|
+
<blockquote className="core-block-quote__inner">
|
|
15
|
+
<p className="core-block-quote__text">{body}</p>
|
|
16
|
+
{cite ? <cite className="core-block-quote__cite">{cite}</cite> : null}
|
|
17
|
+
</blockquote>
|
|
18
|
+
</section>
|
|
19
|
+
);
|
|
20
|
+
}
|