refactor: unify /coffee → /support
- Rename coffee.astro → support.astro (broader scope: ko-fi, crypto, wallets) - Update all /coffee links → /support across website + extension pages - Update nav, footer, hero, and all 5 extension .md files - TabFM coffee banner already points to /support
This commit is contained in:
+156
-66
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { getCollection, type CollectionEntry } from 'astro:content';
|
||||
import { getCollection } from 'astro:content';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const extensions = await getCollection('extensions');
|
||||
@@ -13,13 +13,9 @@ export async function getStaticPaths() {
|
||||
const { ext } = Astro.props;
|
||||
const { Content, headings } = await ext.render();
|
||||
|
||||
// Build table of contents from h2 headings only
|
||||
const tocItems = headings
|
||||
.filter((h: any) => h.depth === 2)
|
||||
.map((h: any) => ({
|
||||
text: h.text,
|
||||
slug: h.slug,
|
||||
}));
|
||||
.map((h: any) => ({ text: h.text, slug: h.slug }));
|
||||
|
||||
const siteUrl = 'https://greasysprocket.com';
|
||||
const canonicalUrl = `${siteUrl}/${ext.slug}`;
|
||||
@@ -27,60 +23,44 @@ const ogImage = ext.data.screenshot
|
||||
? new URL(ext.data.screenshot, siteUrl).href
|
||||
: undefined;
|
||||
|
||||
// JSON-LD: SoftwareApplication
|
||||
const softwareAppLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: ext.data.name,
|
||||
description: ext.data.description,
|
||||
applicationCategory: 'BrowserExtension',
|
||||
operatingSystem: 'Chrome',
|
||||
url: canonicalUrl,
|
||||
offers: {
|
||||
'@type': 'Offer',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
const categoryLabels: Record<string, string> = {
|
||||
'productivity': 'Productivity',
|
||||
'developer-tools': 'Developer Tools',
|
||||
'fun': 'Fun',
|
||||
'privacy': 'Privacy',
|
||||
'utilities': 'Utilities',
|
||||
};
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: ext.data.name,
|
||||
description: ext.data.description,
|
||||
applicationCategory: 'BrowserExtension',
|
||||
operatingSystem: 'Chrome',
|
||||
url: canonicalUrl,
|
||||
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
||||
...(ext.data.chromeStoreUrl && { installUrl: ext.data.chromeStoreUrl }),
|
||||
},
|
||||
...(ext.data.chromeStoreUrl && { installUrl: ext.data.chromeStoreUrl }),
|
||||
};
|
||||
|
||||
// JSON-LD: BreadcrumbList
|
||||
const breadcrumbLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Extensions',
|
||||
item: siteUrl,
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: ext.data.name,
|
||||
item: canonicalUrl,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// JSON-LD: FAQPage (if FAQs exist)
|
||||
const faqLd = ext.data.faq
|
||||
? {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: ext.data.faq.map((f: any) => ({
|
||||
'@type': 'Question',
|
||||
name: f.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: f.answer,
|
||||
},
|
||||
})),
|
||||
}
|
||||
: null;
|
||||
|
||||
const jsonLd = [softwareAppLd, breadcrumbLd, ...(faqLd ? [faqLd] : [])];
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Extensions', item: siteUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: ext.data.name, item: canonicalUrl },
|
||||
],
|
||||
},
|
||||
...(ext.data.faq ? [{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: ext.data.faq.map((f: any) => ({
|
||||
'@type': 'Question',
|
||||
name: f.question,
|
||||
acceptedAnswer: { '@type': 'Answer', text: f.answer },
|
||||
})),
|
||||
}] : []),
|
||||
];
|
||||
---
|
||||
|
||||
<Base
|
||||
@@ -93,9 +73,30 @@ const jsonLd = [softwareAppLd, breadcrumbLd, ...(faqLd ? [faqLd] : [])];
|
||||
<section class="ext-hero">
|
||||
<div class="container">
|
||||
<div class="info">
|
||||
<div class="section-label">{ext.slug}</div>
|
||||
<div class="ext-hero-meta">
|
||||
<span class="ext-tag ext-tag-category" data-category={ext.data.category}>
|
||||
{categoryLabels[ext.data.category] || ext.data.category}
|
||||
</span>
|
||||
<div class="ext-rating-inline" id="rating-display">
|
||||
<span class="ext-stars" id="rating-stars">
|
||||
<span class="star star-empty">★</span>
|
||||
<span class="star star-empty">★</span>
|
||||
<span class="star star-empty">★</span>
|
||||
<span class="star star-empty">★</span>
|
||||
<span class="star star-empty">★</span>
|
||||
</span>
|
||||
<span class="ext-rating-count" id="rating-summary">No ratings yet</span>
|
||||
</div>
|
||||
</div>
|
||||
<h1>{ext.data.name}</h1>
|
||||
<p class="tagline">{ext.data.tagline}</p>
|
||||
{ext.data.tags.length > 0 && (
|
||||
<div class="ext-tags">
|
||||
{ext.data.tags.map((tag: string) => (
|
||||
<span class="tag-pill">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div class="actions">
|
||||
{ext.data.chromeStoreUrl && (
|
||||
<a href={ext.data.chromeStoreUrl} class="btn btn-primary" target="_blank" rel="noopener">
|
||||
@@ -125,14 +126,11 @@ const jsonLd = [softwareAppLd, breadcrumbLd, ...(faqLd ? [faqLd] : [])];
|
||||
<nav>
|
||||
<ol>
|
||||
{tocItems.map((item: any) => (
|
||||
<li>
|
||||
<a href={`#${item.slug}`}>{item.text}</a>
|
||||
</li>
|
||||
<li><a href={`#${item.slug}`}>{item.text}</a></li>
|
||||
))}
|
||||
<li><a href="#rate-this">Rate this</a></li>
|
||||
{ext.data.faq && ext.data.faq.length > 0 && (
|
||||
<li>
|
||||
<a href="#faq">FAQ</a>
|
||||
</li>
|
||||
<li><a href="#faq">FAQ</a></li>
|
||||
)}
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -152,6 +150,21 @@ const jsonLd = [softwareAppLd, breadcrumbLd, ...(faqLd ? [faqLd] : [])];
|
||||
<div class="prose">
|
||||
<Content />
|
||||
</div>
|
||||
|
||||
<!-- Vote widget — integrated at the end of content -->
|
||||
<div class="rate-cta" id="rate-this">
|
||||
<div class="rate-cta-inner">
|
||||
<h3>How would you rate {ext.data.name}?</h3>
|
||||
<p class="rate-cta-subtitle" id="rating-vote-msg">Click a star to vote — no account needed.</p>
|
||||
<div class="rating-vote-stars" id="rating-vote-stars">
|
||||
<button class="star-btn" data-value="1" aria-label="1 star">★</button>
|
||||
<button class="star-btn" data-value="2" aria-label="2 stars">★</button>
|
||||
<button class="star-btn" data-value="3" aria-label="3 stars">★</button>
|
||||
<button class="star-btn" data-value="4" aria-label="4 stars">★</button>
|
||||
<button class="star-btn" data-value="5" aria-label="5 stars">★</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -177,4 +190,81 @@ const jsonLd = [softwareAppLd, breadcrumbLd, ...(faqLd ? [faqLd] : [])];
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<script is:inline define:vars={{ extSlug: ext.slug }}>
|
||||
(async () => {
|
||||
const starsEl = document.getElementById('rating-stars');
|
||||
const summaryEl = document.getElementById('rating-summary');
|
||||
const voteStars = document.getElementById('rating-vote-stars');
|
||||
const voteMsg = document.getElementById('rating-vote-msg');
|
||||
|
||||
function renderStars(el, avg) {
|
||||
const full = Math.floor(avg);
|
||||
const hasHalf = avg % 1 >= 0.25 && avg % 1 < 0.75;
|
||||
const empty = 5 - full - (hasHalf ? 1 : 0);
|
||||
let html = '';
|
||||
for (let i = 0; i < full; i++) html += '<span class="star star-full">★</span>';
|
||||
if (hasHalf) html += '<span class="star star-half">★</span>';
|
||||
for (let i = 0; i < empty; i++) html += '<span class="star star-empty">★</span>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// Load current ratings
|
||||
try {
|
||||
const res = await fetch(`/ratings?ext=${encodeURIComponent(extSlug)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.count === 0) {
|
||||
summaryEl.textContent = 'No ratings yet';
|
||||
} else {
|
||||
renderStars(starsEl, data.average);
|
||||
summaryEl.textContent = `${data.average.toFixed(1)} (${data.count})`;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Vote handling
|
||||
const buttons = voteStars.querySelectorAll('.star-btn');
|
||||
buttons.forEach((btn) => {
|
||||
btn.addEventListener('mouseenter', () => {
|
||||
const val = parseInt(btn.dataset.value);
|
||||
buttons.forEach((b, i) => b.classList.toggle('hover', i < val));
|
||||
});
|
||||
btn.addEventListener('mouseleave', () => {
|
||||
buttons.forEach((b) => b.classList.remove('hover'));
|
||||
});
|
||||
btn.addEventListener('click', async () => {
|
||||
const val = parseInt(btn.dataset.value);
|
||||
buttons.forEach((b, i) => b.classList.toggle('selected', i < val));
|
||||
voteMsg.textContent = 'Submitting...';
|
||||
voteMsg.className = 'rate-cta-subtitle';
|
||||
try {
|
||||
const res = await fetch('/rate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ext: extSlug, rating: val }),
|
||||
});
|
||||
if (res.ok) {
|
||||
voteMsg.textContent = 'Thanks for your vote!';
|
||||
voteMsg.className = 'rate-cta-subtitle success';
|
||||
const r2 = await fetch(`/ratings?ext=${encodeURIComponent(extSlug)}`);
|
||||
if (r2.ok) {
|
||||
const d2 = await r2.json();
|
||||
if (d2.count > 0) {
|
||||
renderStars(starsEl, d2.average);
|
||||
summaryEl.textContent = `${d2.average.toFixed(1)} (${d2.count})`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
voteMsg.textContent = 'Something went wrong. Try again.';
|
||||
voteMsg.className = 'rate-cta-subtitle error';
|
||||
}
|
||||
} catch (e) {
|
||||
voteMsg.textContent = 'Network error. Try again.';
|
||||
voteMsg.className = 'rate-cta-subtitle error';
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</Base>
|
||||
Reference in New Issue
Block a user