Add social share buttons and per-question OG share stubs
This commit is contained in:
+364
-86
@@ -3268,21 +3268,73 @@
|
||||
|
||||
/* ── Q&A Section ── */
|
||||
.section-qa {
|
||||
background: rgba(201, 168, 76, 0.05);
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.15);
|
||||
background:
|
||||
radial-gradient(90% 120% at 100% 0%, rgba(201, 168, 76, 0.12) 0%, rgba(201, 168, 76, 0.02) 50%, rgba(16, 15, 12, 0) 100%),
|
||||
linear-gradient(180deg, rgba(20, 18, 14, 0.96), rgba(13, 12, 10, 0.97));
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.2);
|
||||
}
|
||||
|
||||
.qa-filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.qa-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
|
||||
.qa-view-toggle {
|
||||
border: 1px solid rgba(201, 168, 76, 0.34);
|
||||
background: rgba(201, 168, 76, 0.05);
|
||||
color: #cfba8d;
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.72rem;
|
||||
font-size: 0.76rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qa-view-toggle--active {
|
||||
background: rgba(201, 168, 76, 0.16);
|
||||
color: #f4ddb0;
|
||||
}
|
||||
|
||||
.qa-topics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.qa-tags {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.45rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.qa-tag-btn {
|
||||
border: 1px solid rgba(201, 168, 76, 0.26);
|
||||
background: rgba(201, 168, 76, 0.04);
|
||||
color: #bfa982;
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.62rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.qa-tag-btn--active {
|
||||
background: rgba(201, 168, 76, 0.18);
|
||||
color: #f0d9ab;
|
||||
border-color: rgba(201, 168, 76, 0.5);
|
||||
}
|
||||
|
||||
.qa-topics::-webkit-scrollbar {
|
||||
@@ -3297,11 +3349,11 @@
|
||||
.qa-topic-btn {
|
||||
background: none;
|
||||
border: 1px solid rgba(201, 168, 76, 0.35);
|
||||
color: var(--brand-muted);
|
||||
padding: 0.35rem 0.9rem;
|
||||
border-radius: 2rem;
|
||||
color: #cab483;
|
||||
padding: 0.42rem 0.92rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.85rem;
|
||||
font-family: 'Barlow', sans-serif;
|
||||
transition: background 0.18s, border-color 0.18s, color 0.18s;
|
||||
}
|
||||
@@ -3313,16 +3365,22 @@
|
||||
}
|
||||
|
||||
.qa-topic-btn--active {
|
||||
background: rgba(201, 168, 76, 0.18);
|
||||
background: rgba(201, 168, 76, 0.2);
|
||||
border-color: var(--brand-gold);
|
||||
color: var(--brand-gold);
|
||||
color: #f2ddb0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.qa-search {
|
||||
.qa-topic-btn--full {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: 0.6rem;
|
||||
}
|
||||
|
||||
.qa-search {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.qa-search label {
|
||||
@@ -3345,18 +3403,20 @@
|
||||
}
|
||||
|
||||
.qa-search input {
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(30, 30, 30, 0.8);
|
||||
border: 1px solid rgba(201, 168, 76, 0.3);
|
||||
color: var(--brand-warm-white);
|
||||
width: 100%;
|
||||
padding: 0.78rem 1rem;
|
||||
background: rgba(22, 21, 17, 0.95);
|
||||
border: 1px solid rgba(201, 168, 76, 0.33);
|
||||
color: #f7f0df;
|
||||
font-family: 'Barlow', sans-serif;
|
||||
border-radius: 0.375rem;
|
||||
transition: border-color 0.2s ease;
|
||||
border-radius: 0.55rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.qa-search input:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand-gold);
|
||||
box-shadow: 0 0 0 3px rgba(201, 168, 76, 0.18);
|
||||
}
|
||||
|
||||
.qa-filter-actions {
|
||||
@@ -3364,6 +3424,28 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.qa-filter-chips {
|
||||
margin: 0 0 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.qa-chip {
|
||||
border: 1px solid rgba(201, 168, 76, 0.26);
|
||||
background: rgba(201, 168, 76, 0.06);
|
||||
color: #d2bf95;
|
||||
border-radius: 999px;
|
||||
padding: 0.24rem 0.6rem;
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qa-chip--clear {
|
||||
color: #ead6ac;
|
||||
border-color: rgba(201, 168, 76, 0.5);
|
||||
}
|
||||
|
||||
.qa-clear-btn {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(201, 168, 76, 0.38);
|
||||
@@ -3381,14 +3463,72 @@
|
||||
.qa-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.qa-meta-row {
|
||||
margin: 0 0 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Accordion card ── */
|
||||
.qa-result-count {
|
||||
margin: 0;
|
||||
color: #d7c59d;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.qa-sort-select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.82rem;
|
||||
color: #b8a37a;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.qa-sort-select select {
|
||||
border: 1px solid rgba(201, 168, 76, 0.35);
|
||||
background: rgba(24, 22, 18, 0.95);
|
||||
color: #f1e4c2;
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.33rem 0.58rem;
|
||||
}
|
||||
|
||||
.qa-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 250px) minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.qa-sidebar {
|
||||
border: 1px solid rgba(201, 168, 76, 0.26);
|
||||
background: linear-gradient(180deg, rgba(39, 34, 24, 0.76), rgba(25, 22, 17, 0.9));
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
position: sticky;
|
||||
top: 94px;
|
||||
}
|
||||
|
||||
.qa-sidebar-label {
|
||||
margin: 0 0 0.1rem;
|
||||
color: #ad9971;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.qa-card-scene {
|
||||
border: 1px solid rgba(201, 168, 76, 0.25);
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(35, 30, 20, 0.85);
|
||||
border-radius: 0.68rem;
|
||||
background: linear-gradient(180deg, rgba(32, 28, 20, 0.96), rgba(20, 19, 15, 0.98));
|
||||
transition: border-color 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -3397,56 +3537,87 @@
|
||||
border-color: rgba(201, 168, 76, 0.55);
|
||||
}
|
||||
|
||||
.qa-card-inner {
|
||||
.qa-card-scene--focused {
|
||||
border-color: rgba(201, 168, 76, 0.72);
|
||||
box-shadow: 0 0 0 1px rgba(201, 168, 76, 0.24);
|
||||
}
|
||||
|
||||
.qa-question-header {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.8rem;
|
||||
padding: 1rem 1rem 0.9rem;
|
||||
}
|
||||
|
||||
.qa-card-face {
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.qa-card-front {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
user-select: none;
|
||||
padding: 0.25rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.qa-card-back {
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.2);
|
||||
background: rgba(20, 28, 20, 0.7);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.qa-card-inner.flipped .qa-card-back {
|
||||
display: block;
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.16);
|
||||
background: rgba(17, 24, 17, 0.58);
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
column-gap: 0.8rem;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.qa-face-label {
|
||||
font-size: 1.3rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--brand-gold);
|
||||
flex-shrink: 0;
|
||||
width: 1.75rem;
|
||||
flex: 0 0 1.4rem;
|
||||
}
|
||||
|
||||
.qa-question-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.qa-question-text {
|
||||
margin: 0;
|
||||
font-size: 1.14rem;
|
||||
line-height: 1.6;
|
||||
color: var(--brand-warm-white);
|
||||
font-size: 1.06rem;
|
||||
line-height: 1.5;
|
||||
color: #f6efe0;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.qa-highlight {
|
||||
background: rgba(201, 168, 76, 0.26);
|
||||
color: #f7e9c8;
|
||||
padding: 0.02em 0.18em;
|
||||
border-radius: 0.24em;
|
||||
}
|
||||
|
||||
.qa-question-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #bca67f;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.qa-topic-pill {
|
||||
border: 1px solid rgba(201, 168, 76, 0.35);
|
||||
border-radius: 999px;
|
||||
padding: 0.16rem 0.5rem;
|
||||
color: #e8d0a0;
|
||||
}
|
||||
|
||||
.qa-answer-text {
|
||||
margin: 0;
|
||||
font-size: 1.12rem;
|
||||
line-height: 1.75;
|
||||
color: var(--brand-warm-white);
|
||||
font-size: 1.01rem;
|
||||
line-height: 1.58;
|
||||
color: #efe7d4;
|
||||
opacity: 0.92;
|
||||
white-space: pre-wrap;
|
||||
text-align: left;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.qa-answer-text a {
|
||||
@@ -3459,12 +3630,76 @@
|
||||
color: #f0ead8;
|
||||
}
|
||||
|
||||
.qa-flip-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--brand-muted);
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
font-style: italic;
|
||||
.qa-read-more-btn {
|
||||
margin-top: 0.55rem;
|
||||
border: 1px solid rgba(201, 168, 76, 0.35);
|
||||
background: rgba(201, 168, 76, 0.07);
|
||||
color: #d8c395;
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.58rem;
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qa-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
justify-content: flex-end;
|
||||
padding: 0 1rem 0.65rem;
|
||||
}
|
||||
|
||||
.qa-social-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.62rem;
|
||||
font-size: 0.73rem;
|
||||
font-family: inherit;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.qa-social-btn--x {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-color: rgba(255,255,255,0.15);
|
||||
color: #e7e7e7;
|
||||
}
|
||||
|
||||
.qa-social-btn--x:hover {
|
||||
background: rgba(255,255,255,0.12);
|
||||
border-color: rgba(255,255,255,0.28);
|
||||
}
|
||||
|
||||
.qa-social-btn--fb {
|
||||
background: rgba(24,119,242,0.12);
|
||||
border-color: rgba(24,119,242,0.32);
|
||||
color: #7eaaff;
|
||||
}
|
||||
|
||||
.qa-social-btn--fb:hover {
|
||||
background: rgba(24,119,242,0.22);
|
||||
border-color: rgba(24,119,242,0.5);
|
||||
}
|
||||
|
||||
.qa-share-btn {
|
||||
border: 1px solid rgba(201, 168, 76, 0.35);
|
||||
background: rgba(201, 168, 76, 0.06);
|
||||
color: #d6be8f;
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.66rem;
|
||||
font-size: 0.73rem;
|
||||
font-family: inherit;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qa-share-btn:hover {
|
||||
background: rgba(201, 168, 76, 0.14);
|
||||
}
|
||||
|
||||
.qa-no-results {
|
||||
@@ -3473,6 +3708,26 @@
|
||||
color: var(--brand-muted);
|
||||
}
|
||||
|
||||
.qa-no-results--smart {
|
||||
text-align: left;
|
||||
border: 1px solid rgba(201, 168, 76, 0.24);
|
||||
border-radius: 0.7rem;
|
||||
background: rgba(23, 22, 17, 0.82);
|
||||
}
|
||||
|
||||
.qa-no-results--smart p {
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.qa-inline-link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #e7cf9c;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.qa-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3509,18 +3764,23 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qa-answer-byline {
|
||||
grid-column: 2;
|
||||
margin: 0.55rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: #a89060;
|
||||
font-style: italic;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.qa-filters {
|
||||
gap: 0.65rem;
|
||||
margin-bottom: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.qa-topics {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 0.2rem;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.qa-topic-btn {
|
||||
@@ -3535,28 +3795,43 @@
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.qa-card-face {
|
||||
padding: 1rem 0.9rem;
|
||||
}
|
||||
|
||||
.qa-card-front {
|
||||
.qa-meta-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.qa-face-label {
|
||||
width: 1.2rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
.qa-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.qa-sidebar {
|
||||
position: static;
|
||||
padding: 0.65rem;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.qa-question-header {
|
||||
padding: 0.86rem 0.82rem;
|
||||
gap: 0.62rem;
|
||||
}
|
||||
|
||||
.qa-card-face {
|
||||
padding: 0.15rem 0.82rem 0.86rem;
|
||||
}
|
||||
|
||||
.qa-card-actions {
|
||||
padding: 0 0.82rem 0.62rem;
|
||||
}
|
||||
|
||||
.qa-question-text {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
font-size: 0.97rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.qa-answer-text {
|
||||
font-size: 0.98rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -4321,15 +4596,17 @@
|
||||
|
||||
/* Q&A related question buttons */
|
||||
.qa-related-wrap {
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.18);
|
||||
padding-top: 0.85rem;
|
||||
margin-top: 0.7rem;
|
||||
border-top: 1px solid rgba(201, 168, 76, 0.12);
|
||||
padding-top: 0.6rem;
|
||||
grid-column: 2;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.qa-related-label {
|
||||
margin: 0 0 0.5rem;
|
||||
color: #b9a783;
|
||||
font-size: 0.88rem;
|
||||
color: #a9956c;
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -4338,20 +4615,21 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.qa-related-btn {
|
||||
border: 1px solid rgba(201, 168, 76, 0.35);
|
||||
background: rgba(201, 168, 76, 0.08);
|
||||
color: #d9be86;
|
||||
border: 1px solid rgba(201, 168, 76, 0.22);
|
||||
background: rgba(201, 168, 76, 0.03);
|
||||
color: #bca57b;
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.22rem 0.62rem;
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.qa-related-btn:hover {
|
||||
background: rgba(201, 168, 76, 0.16);
|
||||
background: rgba(201, 168, 76, 0.09);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
||||
+613
-108
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
interface PublicQuestion {
|
||||
id: string
|
||||
@@ -7,24 +6,114 @@ interface PublicQuestion {
|
||||
question: string
|
||||
answer: string
|
||||
topic?: string
|
||||
submittedAt?: string
|
||||
answeredAt?: string
|
||||
}
|
||||
|
||||
const QA_PAGE_SIZE = 6
|
||||
interface DecoratedQuestion extends PublicQuestion {
|
||||
_topic: string
|
||||
_tags: string[]
|
||||
_helpful: number
|
||||
_semanticScore: number
|
||||
}
|
||||
|
||||
function tokenizeForRelated(text: string) {
|
||||
type SortMode = 'relevance' | 'newest' | 'oldest' | 'helpful'
|
||||
|
||||
interface EngagementCounts {
|
||||
shares: number
|
||||
related: number
|
||||
expands: number
|
||||
}
|
||||
|
||||
type EngagementMap = Record<string, EngagementCounts>
|
||||
|
||||
const QA_PAGE_SIZE = 8
|
||||
const ENGAGEMENT_STORAGE_KEY = 'qa-engagement-v1'
|
||||
const MAX_TAGS_PER_QUESTION = 4
|
||||
const MAX_TAGS_VISIBLE = 10
|
||||
|
||||
const STOP_WORDS = new Set([
|
||||
'about', 'after', 'again', 'also', 'always', 'among', 'appears', 'around', 'been', 'before', 'being', 'between', 'both', 'could',
|
||||
'does', 'each', 'every', 'from', 'have', 'into', 'just', 'like', 'many', 'more', 'most', 'much', 'must', 'other', 'over', 'same',
|
||||
'some', 'such', 'than', 'that', 'their', 'there', 'these', 'they', 'this', 'those', 'through', 'very', 'what', 'when', 'where',
|
||||
'which', 'while', 'will', 'with', 'would', 'your', 'you', 'the', 'and', 'for', 'are', 'but', 'not', 'too', 'can', 'how', 'why',
|
||||
'who', 'was', 'were', 'into', 'upon', 'then', 'them', 'ours', 'ourselves', 'himself', 'herself', 'because', 'therefore', 'really',
|
||||
'simply', 'right', 'still', 'even', 'today', 'episode', 'episodes', 'verse', 'verses',
|
||||
])
|
||||
|
||||
const TAG_LEXICON = [
|
||||
'bible', 'scripture', 'faith', 'grace', 'hope', 'salvation', 'gospel', 'mercy', 'obedience', 'leadership', 'elders', 'church',
|
||||
'doctrine', 'discipleship', 'prayer', 'family', 'politics', 'translation', 'reading', 'study', 'titus', 'christian',
|
||||
]
|
||||
|
||||
const SYNONYM_MAP: Record<string, string[]> = {
|
||||
faith: ['belief', 'trust'],
|
||||
trust: ['faith', 'belief'],
|
||||
grace: ['mercy', 'favor'],
|
||||
mercy: ['grace', 'compassion'],
|
||||
hope: ['expectation', 'future'],
|
||||
love: ['charity', 'care'],
|
||||
sin: ['wrong', 'evil'],
|
||||
prayer: ['pray'],
|
||||
pray: ['prayer'],
|
||||
bible: ['scripture', 'word'],
|
||||
scripture: ['bible', 'word'],
|
||||
church: ['fellowship', 'body'],
|
||||
leadership: ['elder', 'pastor'],
|
||||
politics: ['government', 'public'],
|
||||
family: ['home', 'household'],
|
||||
translation: ['version'],
|
||||
salvation: ['saved', 'redeemed'],
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function normalizeToken(token: string) {
|
||||
return token.toLowerCase().replace(/[^a-z0-9]/g, '').trim()
|
||||
}
|
||||
|
||||
function tokenize(text: string) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(token => token.length > 3)
|
||||
.map(normalizeToken)
|
||||
.filter(token => token.length > 2)
|
||||
}
|
||||
|
||||
function renderTextWithLinks(text: string) {
|
||||
function expandQueryTokens(tokens: string[]) {
|
||||
const expanded = new Set<string>()
|
||||
for (const token of tokens) {
|
||||
expanded.add(token)
|
||||
const synonyms = SYNONYM_MAP[token] ?? []
|
||||
for (const synonym of synonyms) expanded.add(synonym)
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
function renderHighlightedText(text: string, query: string) {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return text
|
||||
|
||||
const regex = new RegExp(`(${escapeRegExp(trimmed)})`, 'ig')
|
||||
const parts = text.split(regex)
|
||||
|
||||
return parts.map((part, index) => {
|
||||
if (part.toLowerCase() === trimmed.toLowerCase()) {
|
||||
return <mark key={`mark-${index}`} className="qa-highlight">{part}</mark>
|
||||
}
|
||||
return <span key={`plain-${index}`}>{part}</span>
|
||||
})
|
||||
}
|
||||
|
||||
function renderTextWithLinks(text: string, highlightQuery = '') {
|
||||
const parts = text.split(/(https?:\/\/[^\s]+)/g)
|
||||
|
||||
return parts.map((part, index) => {
|
||||
if (!/^https?:\/\//i.test(part)) {
|
||||
return <span key={`text-${index}`}>{part}</span>
|
||||
return <span key={`text-${index}`}>{renderHighlightedText(part, highlightQuery)}</span>
|
||||
}
|
||||
|
||||
const safeHref = part.replace(/[),.;!?]+$/g, '')
|
||||
@@ -41,13 +130,93 @@ function renderTextWithLinks(text: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function readEngagementFromStorage(): EngagementMap {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(ENGAGEMENT_STORAGE_KEY)
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw)
|
||||
return typeof parsed === 'object' && parsed ? (parsed as EngagementMap) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function truncateAnswer(text: string, limit = 260) {
|
||||
if (text.length <= limit) return text
|
||||
const sliced = text.slice(0, limit)
|
||||
const safeCut = sliced.lastIndexOf(' ')
|
||||
return `${sliced.slice(0, safeCut > 120 ? safeCut : limit).trim()}...`
|
||||
}
|
||||
|
||||
function semanticScoreForQuestion(question: PublicQuestion, tags: string[], query: string) {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
if (!normalized) return 0
|
||||
|
||||
const queryTokens = tokenize(normalized)
|
||||
if (queryTokens.length === 0) return 0
|
||||
|
||||
const expanded = expandQueryTokens(queryTokens)
|
||||
const questionTokens = new Set(tokenize(`${question.question} ${question.answer} ${question.topic ?? ''} ${tags.join(' ')}`))
|
||||
|
||||
let score = 0
|
||||
for (const token of expanded) {
|
||||
if (questionTokens.has(token)) score += 2
|
||||
}
|
||||
|
||||
const lowerQuestion = question.question.toLowerCase()
|
||||
const lowerAnswer = question.answer.toLowerCase()
|
||||
const lowerTopic = (question.topic ?? '').toLowerCase()
|
||||
|
||||
if (lowerQuestion.includes(normalized)) score += 6
|
||||
if (lowerAnswer.includes(normalized)) score += 3
|
||||
if (lowerTopic.includes(normalized)) score += 2
|
||||
|
||||
for (const token of queryTokens) {
|
||||
if (token.length < 4) continue
|
||||
if (lowerQuestion.includes(token)) score += 1
|
||||
if (lowerAnswer.includes(token)) score += 0.5
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
function tokenizeForRelated(text: string) {
|
||||
return tokenize(text).filter(token => token.length > 3 && !STOP_WORDS.has(token))
|
||||
}
|
||||
|
||||
function extractAutoTags(question: PublicQuestion) {
|
||||
const source = `${question.question} ${question.answer}`.toLowerCase()
|
||||
const topicToken = normalizeToken(question.topic ?? '')
|
||||
|
||||
const lexiconHits = TAG_LEXICON
|
||||
.filter(tag => source.includes(tag))
|
||||
.filter(tag => tag !== topicToken)
|
||||
|
||||
if (lexiconHits.length > 0) {
|
||||
return Array.from(new Set(lexiconHits)).slice(0, MAX_TAGS_PER_QUESTION)
|
||||
}
|
||||
|
||||
const questionOnlyTokens = tokenize(question.question)
|
||||
.filter(token => token.length >= 5 && !STOP_WORDS.has(token) && token !== topicToken)
|
||||
.slice(0, MAX_TAGS_PER_QUESTION)
|
||||
|
||||
return Array.from(new Set(questionOnlyTokens))
|
||||
}
|
||||
|
||||
export default function QASection() {
|
||||
const [questions, setQuestions] = useState<PublicQuestion[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedTopic, setSelectedTopic] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<{ [key: string]: boolean }>({})
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null)
|
||||
const [focusedQuestionId, setFocusedQuestionId] = useState<string | null>(null)
|
||||
const [sortMode, setSortMode] = useState<SortMode>('relevance')
|
||||
const [compactMode, setCompactMode] = useState(false)
|
||||
const [page, setPage] = useState(0)
|
||||
const [engagement, setEngagement] = useState<EngagementMap>({})
|
||||
const [expandedCompactAnswers, setExpandedCompactAnswers] = useState<Record<string, boolean>>({})
|
||||
const [copiedQuestionId, setCopiedQuestionId] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const resultsTopRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/questions')
|
||||
@@ -61,49 +230,236 @@ export default function QASection() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const topics = Array.from(new Set(questions.map(q => q.topic).filter(Boolean))) as string[]
|
||||
useEffect(() => {
|
||||
setEngagement(readEngagementFromStorage())
|
||||
}, [])
|
||||
|
||||
const filteredQuestions = questions.filter(q => {
|
||||
const matchesTopic = !selectedTopic || q.topic === selectedTopic
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
q.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
q.answer.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
return matchesTopic && matchesSearch
|
||||
})
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(ENGAGEMENT_STORAGE_KEY, JSON.stringify(engagement))
|
||||
}, [engagement])
|
||||
|
||||
const totalPages = Math.ceil(filteredQuestions.length / QA_PAGE_SIZE)
|
||||
const pagedQuestions = filteredQuestions.slice(page * QA_PAGE_SIZE, (page + 1) * QA_PAGE_SIZE)
|
||||
const incrementEngagement = useCallback((id: string, metric: keyof EngagementCounts) => {
|
||||
setEngagement(prev => {
|
||||
const current = prev[id] ?? { shares: 0, related: 0, expands: 0 }
|
||||
return {
|
||||
...prev,
|
||||
[id]: {
|
||||
...current,
|
||||
[metric]: current[metric] + 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpanded(state => ({ ...state, [id]: !state[id] }))
|
||||
const normalizedSearch = searchQuery.trim().toLowerCase()
|
||||
|
||||
const questionSet = useMemo(() => {
|
||||
return questions.map(question => {
|
||||
const topic = question.topic?.trim() || 'General'
|
||||
const tags = extractAutoTags(question)
|
||||
const counts = engagement[question.id] ?? { shares: 0, related: 0, expands: 0 }
|
||||
const helpful = counts.shares * 3 + counts.related * 2 + counts.expands
|
||||
const semantic = semanticScoreForQuestion(question, tags, normalizedSearch)
|
||||
return {
|
||||
...question,
|
||||
_topic: topic,
|
||||
_tags: tags,
|
||||
_helpful: helpful,
|
||||
_semanticScore: semantic,
|
||||
}
|
||||
})
|
||||
}, [engagement, normalizedSearch, questions])
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromHash = () => {
|
||||
const hash = window.location.hash
|
||||
if (!hash.startsWith('#qa-')) return
|
||||
const id = decodeURIComponent(hash.slice(4))
|
||||
const matched = questionSet.find(item => item.id === id)
|
||||
if (!matched) return
|
||||
|
||||
setFocusedQuestionId(id)
|
||||
setSelectedTopic(matched._topic)
|
||||
|
||||
window.setTimeout(() => {
|
||||
const element = document.getElementById(`qa-${id}`)
|
||||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 80)
|
||||
}
|
||||
|
||||
syncFromHash()
|
||||
window.addEventListener('hashchange', syncFromHash)
|
||||
return () => window.removeEventListener('hashchange', syncFromHash)
|
||||
}, [questionSet])
|
||||
|
||||
const topicCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const question of questionSet) {
|
||||
counts.set(question._topic, (counts.get(question._topic) ?? 0) + 1)
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.map(([topic, count]) => ({ topic, count }))
|
||||
.sort((a, b) => b.count - a.count || a.topic.localeCompare(b.topic))
|
||||
}, [questionSet])
|
||||
|
||||
const tagCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const question of questionSet) {
|
||||
for (const tag of question._tags) {
|
||||
counts.set(tag, (counts.get(tag) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return Array.from(counts.entries())
|
||||
.map(([tag, count]) => ({ tag, count }))
|
||||
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
|
||||
.slice(0, MAX_TAGS_VISIBLE)
|
||||
}, [questionSet])
|
||||
|
||||
const filteredQuestions = useMemo(() => {
|
||||
const output = questionSet.filter(question => {
|
||||
const matchesTopic = !selectedTopic || question._topic === selectedTopic
|
||||
const matchesTag = !selectedTag || question._tags.includes(selectedTag)
|
||||
const matchesSearch = !normalizedSearch || question._semanticScore > 0
|
||||
return matchesTopic && matchesTag && matchesSearch
|
||||
})
|
||||
|
||||
const timestamp = (question: PublicQuestion) => {
|
||||
const value = question.answeredAt ?? question.submittedAt
|
||||
return value ? new Date(value).getTime() : 0
|
||||
}
|
||||
|
||||
output.sort((a, b) => {
|
||||
if (sortMode === 'newest') return timestamp(b) - timestamp(a)
|
||||
if (sortMode === 'oldest') return timestamp(a) - timestamp(b)
|
||||
if (sortMode === 'helpful') {
|
||||
if (b._helpful !== a._helpful) return b._helpful - a._helpful
|
||||
return timestamp(b) - timestamp(a)
|
||||
}
|
||||
if (b._semanticScore !== a._semanticScore) return b._semanticScore - a._semanticScore
|
||||
return timestamp(b) - timestamp(a)
|
||||
})
|
||||
|
||||
return output
|
||||
}, [normalizedSearch, questionSet, selectedTag, selectedTopic, sortMode])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / QA_PAGE_SIZE))
|
||||
const safePage = Math.min(page, totalPages - 1)
|
||||
const pageStart = safePage * QA_PAGE_SIZE
|
||||
const pageEnd = Math.min(pageStart + QA_PAGE_SIZE, filteredQuestions.length)
|
||||
const pagedQuestions = filteredQuestions.slice(pageStart, pageEnd)
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0)
|
||||
}, [normalizedSearch, selectedTag, selectedTopic, sortMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (page > totalPages - 1) setPage(totalPages - 1)
|
||||
}, [page, totalPages])
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusedQuestionId) return
|
||||
const timer = window.setTimeout(() => setFocusedQuestionId(null), 2200)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [focusedQuestionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusedQuestionId) return
|
||||
const index = filteredQuestions.findIndex(item => item.id === focusedQuestionId)
|
||||
if (index === -1) return
|
||||
const nextPage = Math.floor(index / QA_PAGE_SIZE)
|
||||
if (nextPage !== safePage) setPage(nextPage)
|
||||
}, [filteredQuestions, focusedQuestionId, safePage])
|
||||
|
||||
const goToPage = (nextPage: number) => {
|
||||
setPage(nextPage)
|
||||
window.setTimeout(() => {
|
||||
resultsTopRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 40)
|
||||
}
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchQuery(value)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const handleTopic = (topic: string | null) => {
|
||||
setSelectedTopic(topic)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const handleTag = (tag: string | null) => {
|
||||
setSelectedTag(tag)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSelectedTopic(null)
|
||||
setSelectedTag(null)
|
||||
setSearchQuery('')
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const getRelatedQuestions = (current: PublicQuestion) => {
|
||||
const getShareUrl = (id: string) =>
|
||||
`${window.location.origin}/questions#qa-${encodeURIComponent(id)}`
|
||||
|
||||
const getSocialUrl = (id: string) =>
|
||||
`${window.location.origin}/questions/share/${encodeURIComponent(id)}`
|
||||
|
||||
const shareQuestion = async (id: string) => {
|
||||
const shareUrl = getShareUrl(id)
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
incrementEngagement(id, 'shares')
|
||||
setCopiedQuestionId(id)
|
||||
window.history.replaceState(null, '', `/questions#qa-${encodeURIComponent(id)}`)
|
||||
window.setTimeout(() => setCopiedQuestionId(curr => (curr === id ? null : curr)), 1800)
|
||||
} catch {
|
||||
window.prompt('Copy this link:', shareUrl)
|
||||
}
|
||||
}
|
||||
|
||||
const shareToX = (question: DecoratedQuestion) => {
|
||||
const url = getSocialUrl(question.id)
|
||||
const text = question.question.length > 200
|
||||
? `${question.question.slice(0, 197)}…`
|
||||
: question.question
|
||||
window.open(
|
||||
`https://x.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer,width=600,height=420'
|
||||
)
|
||||
incrementEngagement(question.id, 'shares')
|
||||
}
|
||||
|
||||
const shareToFacebook = (id: string) => {
|
||||
const url = getSocialUrl(id)
|
||||
window.open(
|
||||
`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer,width=600,height=500'
|
||||
)
|
||||
incrementEngagement(id, 'shares')
|
||||
}
|
||||
|
||||
const formatDate = (value?: string) => {
|
||||
if (!value) return null
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) return null
|
||||
return parsed.toLocaleDateString()
|
||||
}
|
||||
|
||||
const toggleCompactAnswer = (id: string) => {
|
||||
const next = !expandedCompactAnswers[id]
|
||||
setExpandedCompactAnswers(prev => ({ ...prev, [id]: next }))
|
||||
if (next) incrementEngagement(id, 'expands')
|
||||
}
|
||||
|
||||
const getRelatedQuestions = (current: DecoratedQuestion) => {
|
||||
const currentTokens = new Set(tokenizeForRelated(`${current.question} ${current.answer}`))
|
||||
|
||||
return questions
|
||||
return questionSet
|
||||
.filter(candidate => candidate.id !== current.id)
|
||||
.map(candidate => {
|
||||
const candidateTokens = tokenizeForRelated(`${candidate.question} ${candidate.answer}`)
|
||||
const overlap = candidateTokens.filter(token => currentTokens.has(token)).length
|
||||
const sameTopic = Boolean(current.topic && candidate.topic && current.topic === candidate.topic)
|
||||
const sameTopic = current._topic === candidate._topic
|
||||
const score = overlap + (sameTopic ? 5 : 0)
|
||||
return { candidate, score }
|
||||
})
|
||||
@@ -113,6 +469,9 @@ export default function QASection() {
|
||||
.map(item => item.candidate)
|
||||
}
|
||||
|
||||
const topSuggestedTopic = topicCounts[0]?.topic ?? null
|
||||
const mostHelpfulQuestion = [...questionSet].sort((a, b) => b._helpful - a._helpful)[0] ?? null
|
||||
|
||||
return (
|
||||
<section id="qa" className="section-qa" aria-label="Questions and answers">
|
||||
<div className="section-inner">
|
||||
@@ -121,13 +480,23 @@ export default function QASection() {
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<p className="qa-no-results">Loading questions…</p>
|
||||
<p className="qa-no-results">Loading questions...</p>
|
||||
) : questions.length === 0 ? (
|
||||
<div className="qa-no-results">
|
||||
<p>No questions have been answered yet. <a href="#contact">Submit yours below!</a></p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="qa-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className={`qa-view-toggle${compactMode ? ' qa-view-toggle--active' : ''}`}
|
||||
onClick={() => setCompactMode(value => !value)}
|
||||
>
|
||||
{compactMode ? 'Compact view: On' : 'Compact view: Off'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="qa-filters">
|
||||
<div className="qa-topics">
|
||||
<button
|
||||
@@ -137,7 +506,7 @@ export default function QASection() {
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{topics.map(topic => (
|
||||
{topicCounts.map(({ topic }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={topic}
|
||||
@@ -148,108 +517,244 @@ export default function QASection() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="qa-tags">
|
||||
<button
|
||||
type="button"
|
||||
className={`qa-tag-btn${selectedTag === null ? ' qa-tag-btn--active' : ''}`}
|
||||
onClick={() => handleTag(null)}
|
||||
>
|
||||
All tags
|
||||
</button>
|
||||
{tagCounts.map(({ tag }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tag}
|
||||
className={`qa-tag-btn${selectedTag === tag ? ' qa-tag-btn--active' : ''}`}
|
||||
onClick={() => handleTag(tag)}
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="qa-search">
|
||||
<label>
|
||||
<span className="visually-hidden">Search Questions</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search questions…"
|
||||
placeholder="Search naturally (example: how to stay consistent in Bible reading)"
|
||||
value={searchQuery}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{(searchQuery || selectedTopic) && (
|
||||
<div className="qa-filter-actions">
|
||||
<button type="button" className="qa-clear-btn" onClick={clearFilters}>Clear filters</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(searchQuery || selectedTopic || selectedTag) && (
|
||||
<div className="qa-filter-chips" aria-label="Active filters">
|
||||
{searchQuery && (
|
||||
<button type="button" className="qa-chip" onClick={() => setSearchQuery('')}>
|
||||
Search: {searchQuery} x
|
||||
</button>
|
||||
)}
|
||||
{selectedTopic && (
|
||||
<button type="button" className="qa-chip" onClick={() => setSelectedTopic(null)}>
|
||||
Topic: {selectedTopic} x
|
||||
</button>
|
||||
)}
|
||||
{selectedTag && (
|
||||
<button type="button" className="qa-chip" onClick={() => setSelectedTag(null)}>
|
||||
Tag: #{selectedTag} x
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="qa-chip qa-chip--clear" onClick={clearFilters}>Clear all</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredQuestions.length === 0 ? (
|
||||
<div className="qa-no-results">
|
||||
<p>No matching questions found. <a href="#contact">Submit your question</a></p>
|
||||
<div className="qa-no-results qa-no-results--smart">
|
||||
<p>No matches for this search yet.</p>
|
||||
{topSuggestedTopic && (
|
||||
<p>
|
||||
Try browsing <button type="button" className="qa-inline-link" onClick={() => { clearFilters(); setSelectedTopic(topSuggestedTopic) }}>{topSuggestedTopic}</button> questions.
|
||||
</p>
|
||||
)}
|
||||
{mostHelpfulQuestion && (
|
||||
<p>
|
||||
Or jump to <button type="button" className="qa-inline-link" onClick={() => {
|
||||
clearFilters()
|
||||
setFocusedQuestionId(mostHelpfulQuestion.id)
|
||||
window.history.replaceState(null, '', `/questions#qa-${encodeURIComponent(mostHelpfulQuestion.id)}`)
|
||||
}}>{mostHelpfulQuestion.question}</button>.
|
||||
</p>
|
||||
)}
|
||||
<p><a href="#contact">Submit your question</a> and Nate may add it here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="qa-cards">
|
||||
{pagedQuestions.map(question => {
|
||||
const relatedQuestions = getRelatedQuestions(question)
|
||||
return (
|
||||
<div key={question.id} className="qa-card-scene">
|
||||
<div
|
||||
className={`qa-card-inner ${expanded[question.id] ? 'flipped' : ''}`}
|
||||
onClick={() => toggleExpanded(question.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={!!expanded[question.id]}
|
||||
aria-label={question.question}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') toggleExpanded(question.id)
|
||||
}}
|
||||
>
|
||||
<div className="qa-card-face qa-card-front">
|
||||
<span className="qa-face-label">Q</span>
|
||||
<p className="qa-question-text">{question.question}</p>
|
||||
<span className="qa-flip-hint">{expanded[question.id] ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
<div className="qa-card-face qa-card-back">
|
||||
<span className="qa-face-label">A</span>
|
||||
<div className="qa-answer-text">{renderTextWithLinks(question.answer)}</div>
|
||||
{relatedQuestions.length > 0 && (
|
||||
<div className="qa-related-wrap">
|
||||
<p className="qa-related-label">Related questions</p>
|
||||
<div className="qa-related-list">
|
||||
{relatedQuestions.map(related => (
|
||||
<button
|
||||
key={related.id}
|
||||
type="button"
|
||||
className="qa-related-btn"
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleTopic(null)
|
||||
handleSearch(related.question)
|
||||
setExpanded({ [related.id]: true })
|
||||
}}
|
||||
>
|
||||
{related.question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p style={{ margin: '0.75rem 0 0', fontSize: '0.8rem', color: '#a89060', fontStyle: 'italic' }}>
|
||||
— Answered by Nate
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)})}
|
||||
<div className="qa-meta-row">
|
||||
<p className="qa-result-count">
|
||||
{filteredQuestions.length} result{filteredQuestions.length === 1 ? '' : 's'}
|
||||
{normalizedSearch && <span> for "{normalizedSearch}"</span>}
|
||||
{filteredQuestions.length > 0 && <span> · Showing {pageStart + 1}-{pageEnd}</span>}
|
||||
</p>
|
||||
<label className="qa-sort-select" htmlFor="qa-sort-mode">
|
||||
Sort
|
||||
<select id="qa-sort-mode" value={sortMode} onChange={e => setSortMode(e.target.value as SortMode)}>
|
||||
<option value="relevance">Best match</option>
|
||||
<option value="helpful">Most helpful</option>
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="qa-pagination">
|
||||
<div className="qa-layout">
|
||||
<aside className="qa-sidebar" aria-label="Question topics">
|
||||
<p className="qa-sidebar-label">Browse by topic</p>
|
||||
<button
|
||||
className="qa-page-btn"
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
disabled={page === 0}
|
||||
aria-label="Previous page"
|
||||
type="button"
|
||||
className={`qa-topic-btn qa-topic-btn--full${selectedTopic === null ? ' qa-topic-btn--active' : ''}`}
|
||||
onClick={() => handleTopic(null)}
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
<span className="qa-page-info">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="qa-page-btn"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next ›
|
||||
<span>All topics</span>
|
||||
<span>{questions.length}</span>
|
||||
</button>
|
||||
{topicCounts.map(({ topic, count }) => (
|
||||
<button
|
||||
type="button"
|
||||
key={topic}
|
||||
className={`qa-topic-btn qa-topic-btn--full${selectedTopic === topic ? ' qa-topic-btn--active' : ''}`}
|
||||
onClick={() => handleTopic(topic)}
|
||||
>
|
||||
<span>{topic}</span>
|
||||
<span>{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<div className="qa-cards">
|
||||
<div ref={resultsTopRef} aria-hidden="true" />
|
||||
{pagedQuestions.map(question => {
|
||||
const relatedQuestions = getRelatedQuestions(question)
|
||||
const isFocused = focusedQuestionId === question.id
|
||||
const isExpandedInCompact = expandedCompactAnswers[question.id] === true
|
||||
const answerText = compactMode && !isExpandedInCompact
|
||||
? truncateAnswer(question.answer)
|
||||
: question.answer
|
||||
|
||||
return (
|
||||
<article key={question.id} id={`qa-${question.id}`} className={`qa-card-scene${isFocused ? ' qa-card-scene--focused' : ''}`}>
|
||||
<div className="qa-question-header">
|
||||
<span className="qa-face-label">Q</span>
|
||||
<span className="qa-question-main">
|
||||
<span className="qa-question-text">{renderHighlightedText(question.question, normalizedSearch)}</span>
|
||||
<span className="qa-question-meta">
|
||||
<span className="qa-topic-pill">{question._topic}</span>
|
||||
{formatDate(question.answeredAt ?? question.submittedAt) && (
|
||||
<span>{formatDate(question.answeredAt ?? question.submittedAt)}</span>
|
||||
)}
|
||||
{question._helpful > 0 && <span>{question._helpful} helpful</span>}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="qa-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Share on X"
|
||||
className="qa-social-btn qa-social-btn--x"
|
||||
onClick={() => shareToX(question)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" width="14" height="14"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.746l7.73-8.835L1.254 2.25H8.08l4.253 5.622zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
|
||||
<span>Share</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Share on Facebook"
|
||||
className="qa-social-btn qa-social-btn--fb"
|
||||
onClick={() => shareToFacebook(question.id)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" width="14" height="14"><path d="M24 12.073C24 5.404 18.627 0 12 0S0 5.404 0 12.073C0 18.1 4.388 23.094 10.125 24v-8.437H7.078v-3.49h3.047V9.41c0-3.025 1.792-4.697 4.533-4.697 1.312 0 2.686.236 2.686.236v2.97h-1.513c-1.491 0-1.956.93-1.956 1.884v2.25h3.328l-.532 3.49h-2.796V24C19.612 23.094 24 18.1 24 12.073z"/></svg>
|
||||
<span>Share</span>
|
||||
</button>
|
||||
<button type="button" className="qa-share-btn" onClick={() => shareQuestion(question.id)}>
|
||||
{copiedQuestionId === question.id ? '✓ Copied' : 'Copy link'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id={`qa-answer-${question.id}`} className="qa-card-face qa-card-back">
|
||||
<span className="qa-face-label">A</span>
|
||||
<div className="qa-answer-text">
|
||||
{renderTextWithLinks(answerText, normalizedSearch)}
|
||||
{compactMode && question.answer.length > 260 && (
|
||||
<button
|
||||
type="button"
|
||||
className="qa-read-more-btn"
|
||||
onClick={() => toggleCompactAnswer(question.id)}
|
||||
>
|
||||
{isExpandedInCompact ? 'Show less' : 'Read full answer'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{relatedQuestions.length > 0 && (
|
||||
<div className="qa-related-wrap">
|
||||
<p className="qa-related-label">Related questions</p>
|
||||
<div className="qa-related-list">
|
||||
{relatedQuestions.map(related => (
|
||||
<button
|
||||
key={related.id}
|
||||
type="button"
|
||||
className="qa-related-btn"
|
||||
onClick={() => {
|
||||
incrementEngagement(question.id, 'related')
|
||||
setSelectedTopic(related._topic)
|
||||
setSelectedTag(null)
|
||||
setSearchQuery('')
|
||||
setFocusedQuestionId(related.id)
|
||||
window.history.replaceState(null, '', `/questions#qa-${encodeURIComponent(related.id)}`)
|
||||
window.setTimeout(() => {
|
||||
const element = document.getElementById(`qa-${related.id}`)
|
||||
element?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 30)
|
||||
}}
|
||||
>
|
||||
{related.question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="qa-answer-byline">Answered by Nate</p>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="qa-pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="qa-page-btn"
|
||||
onClick={() => goToPage(Math.max(0, safePage - 1))}
|
||||
disabled={safePage === 0}
|
||||
aria-label="Previous questions page"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="qa-page-info">Page {safePage + 1} / {totalPages}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="qa-page-btn"
|
||||
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
aria-label="Next questions page"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user