mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(help): add centralized help system with shared packages
- Add @manacore/shared-help-types with TypeScript types and Zod schemas - Add @manacore/shared-help-content with Markdown parser, content loader, and Fuse.js search - Add @manacore/shared-help-ui with Svelte 5 components (HelpPage, FAQSection, FeaturesOverview, etc.) - Add @manacore/shared-help-mobile with React Native components for Expo apps - Add help translations to shared-i18n (de, en, fr, it, es) - Implement self-contained help page in Contacts app with FAQ, Features, Shortcuts, and Contact sections - Support i18n with German and English content 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b6158a89a6
commit
1dda437192
60 changed files with 5482 additions and 0 deletions
468
apps/contacts/apps/web/src/routes/(app)/help/+page.svelte
Normal file
468
apps/contacts/apps/web/src/routes/(app)/help/+page.svelte
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { locale, _ } from 'svelte-i18n';
|
||||
|
||||
type HelpSection = 'faq' | 'features' | 'shortcuts' | 'contact';
|
||||
|
||||
let activeSection = $state<HelpSection>('faq');
|
||||
let searchQuery = $state('');
|
||||
let expandedFaqId = $state<string | null>('faq-1');
|
||||
|
||||
// FAQ data
|
||||
const faqs = $derived(
|
||||
$locale === 'de'
|
||||
? [
|
||||
{
|
||||
id: 'faq-1',
|
||||
question: 'Wie importiere ich Kontakte aus Google?',
|
||||
answer:
|
||||
'Um Kontakte aus Google zu importieren: Gehe zu Daten > Import, wähle Google Kontakte, melde dich bei deinem Google-Konto an, wähle die Kontakte aus und klicke auf Importieren.',
|
||||
category: 'features',
|
||||
},
|
||||
{
|
||||
id: 'faq-2',
|
||||
question: 'Wie exportiere ich meine Kontakte?',
|
||||
answer:
|
||||
'Du kannst deine Kontakte in verschiedenen Formaten exportieren: Gehe zu Daten > Export, wähle das gewünschte Format (vCard, CSV, JSON) und klicke auf Exportieren.',
|
||||
category: 'features',
|
||||
},
|
||||
{
|
||||
id: 'faq-3',
|
||||
question: 'Wie finde ich doppelte Kontakte?',
|
||||
answer:
|
||||
'Wir erkennen automatisch potenzielle Duplikate. Gehe zu Duplikate in der Seitenleiste, überprüfe die Vorschläge und wähle Zusammenführen oder Ignorieren.',
|
||||
category: 'features',
|
||||
},
|
||||
{
|
||||
id: 'faq-4',
|
||||
question: 'Wie kann ich mein Abo kündigen?',
|
||||
answer:
|
||||
'Du kannst dein Abo jederzeit kündigen: Gehe zu Einstellungen > Abonnement > Abo verwalten > Abo kündigen. Dein Abo bleibt bis zum Ende des Abrechnungszeitraums aktiv.',
|
||||
category: 'billing',
|
||||
},
|
||||
{
|
||||
id: 'faq-5',
|
||||
question: 'Wie werden meine Daten geschützt?',
|
||||
answer:
|
||||
'Wir nehmen deinen Datenschutz ernst: Alle Daten werden verschlüsselt (TLS), wir sind DSGVO-konform, verkaufen keine Daten und du kannst jederzeit alle Daten exportieren oder dein Konto löschen.',
|
||||
category: 'privacy',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: 'faq-1',
|
||||
question: 'How do I import contacts from Google?',
|
||||
answer:
|
||||
'To import contacts from Google: Go to Data > Import, select Google Contacts, sign in to your Google account, select the contacts you want to import, and click Import.',
|
||||
category: 'features',
|
||||
},
|
||||
{
|
||||
id: 'faq-2',
|
||||
question: 'How do I export my contacts?',
|
||||
answer:
|
||||
'You can export your contacts in various formats: Go to Data > Export, select the desired format (vCard, CSV, JSON), and click Export.',
|
||||
category: 'features',
|
||||
},
|
||||
{
|
||||
id: 'faq-3',
|
||||
question: 'How do I find duplicate contacts?',
|
||||
answer:
|
||||
'We automatically detect potential duplicates. Go to Duplicates in the sidebar, review the suggestions, and choose to Merge or Ignore.',
|
||||
category: 'features',
|
||||
},
|
||||
{
|
||||
id: 'faq-4',
|
||||
question: 'How do I cancel my subscription?',
|
||||
answer:
|
||||
'You can cancel your subscription at any time: Go to Settings > Subscription > Manage Subscription > Cancel Subscription. Your subscription will remain active until the end of the billing period.',
|
||||
category: 'billing',
|
||||
},
|
||||
{
|
||||
id: 'faq-5',
|
||||
question: 'How is my data protected?',
|
||||
answer:
|
||||
'We take your privacy seriously: All data is encrypted (TLS), we are GDPR compliant, we never sell data, and you can export all your data or delete your account at any time.',
|
||||
category: 'privacy',
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// Features data
|
||||
const features = $derived(
|
||||
$locale === 'de'
|
||||
? [
|
||||
{
|
||||
icon: '👥',
|
||||
title: 'Kontaktverwaltung',
|
||||
description: 'Verwalte alle deine Kontakte an einem Ort',
|
||||
highlights: [
|
||||
'Unbegrenzte Kontakte',
|
||||
'Benutzerdefinierte Felder',
|
||||
'Tags und Kategorien',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '📤',
|
||||
title: 'Import & Export',
|
||||
description: 'Importiere und exportiere Kontakte in verschiedenen Formaten',
|
||||
highlights: ['Google Kontakte Sync', 'vCard Import/Export', 'CSV Import/Export'],
|
||||
},
|
||||
{
|
||||
icon: '🔍',
|
||||
title: 'Duplikat-Erkennung',
|
||||
description: 'Automatische Erkennung und Zusammenführung von Duplikaten',
|
||||
highlights: [
|
||||
'Intelligente Erkennung',
|
||||
'Ein-Klick Zusammenführung',
|
||||
'Überprüfungsmodus',
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: '⭐',
|
||||
title: 'Favoriten',
|
||||
description: 'Markiere wichtige Kontakte als Favoriten',
|
||||
highlights: ['Schnellzugriff', 'Verschiedene Ansichten', 'Sortierung'],
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
icon: '👥',
|
||||
title: 'Contact Management',
|
||||
description: 'Manage all your contacts in one place',
|
||||
highlights: ['Unlimited contacts', 'Custom fields', 'Tags and categories'],
|
||||
},
|
||||
{
|
||||
icon: '📤',
|
||||
title: 'Import & Export',
|
||||
description: 'Import and export contacts in various formats',
|
||||
highlights: ['Google Contacts sync', 'vCard import/export', 'CSV import/export'],
|
||||
},
|
||||
{
|
||||
icon: '🔍',
|
||||
title: 'Duplicate Detection',
|
||||
description: 'Automatic detection and merging of duplicates',
|
||||
highlights: ['Smart detection', 'One-click merge', 'Review mode'],
|
||||
},
|
||||
{
|
||||
icon: '⭐',
|
||||
title: 'Favorites',
|
||||
description: 'Mark important contacts as favorites',
|
||||
highlights: ['Quick access', 'Multiple views', 'Sorting'],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
// Keyboard shortcuts
|
||||
const shortcuts = [
|
||||
{ shortcut: 'Cmd/Ctrl + K', action: $locale === 'de' ? 'Suche öffnen' : 'Open search' },
|
||||
{ shortcut: 'Cmd/Ctrl + N', action: $locale === 'de' ? 'Neuer Kontakt' : 'New contact' },
|
||||
{ shortcut: 'Cmd/Ctrl + 1-6', action: $locale === 'de' ? 'Navigation' : 'Navigation' },
|
||||
];
|
||||
|
||||
// Translations
|
||||
const t = $derived({
|
||||
title: $locale === 'de' ? 'Hilfe & Support' : 'Help & Support',
|
||||
subtitle:
|
||||
$locale === 'de'
|
||||
? 'Finde Antworten und lerne die App kennen'
|
||||
: 'Find answers and learn how to use the app',
|
||||
searchPlaceholder: $locale === 'de' ? 'Hilfe durchsuchen...' : 'Search help...',
|
||||
sections: {
|
||||
faq: 'FAQ',
|
||||
features: 'Features',
|
||||
shortcuts: $locale === 'de' ? 'Tastenkürzel' : 'Shortcuts',
|
||||
contact: $locale === 'de' ? 'Kontakt' : 'Contact',
|
||||
},
|
||||
back: $locale === 'de' ? 'Zurück' : 'Back',
|
||||
contactTitle: $locale === 'de' ? 'Support kontaktieren' : 'Contact Support',
|
||||
contactDescription:
|
||||
$locale === 'de'
|
||||
? 'Unser Support-Team hilft dir bei allen Fragen.'
|
||||
: 'Our support team is here to help you.',
|
||||
email: $locale === 'de' ? 'E-Mail senden' : 'Send email',
|
||||
responseTime: $locale === 'de' ? 'Antwortzeit: 24 Stunden' : 'Response time: 24 hours',
|
||||
});
|
||||
|
||||
// Filter FAQs based on search
|
||||
const filteredFaqs = $derived(
|
||||
searchQuery.trim().length > 0
|
||||
? faqs.filter(
|
||||
(faq) =>
|
||||
faq.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
faq.answer.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: faqs
|
||||
);
|
||||
|
||||
function toggleFaq(id: string) {
|
||||
expandedFaqId = expandedFaqId === id ? null : id;
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
goto('/');
|
||||
}
|
||||
|
||||
const sections: { id: HelpSection; label: string }[] = $derived([
|
||||
{ id: 'faq', label: t.sections.faq },
|
||||
{ id: 'features', label: t.sections.features },
|
||||
{ id: 'shortcuts', label: t.sections.shortcuts },
|
||||
{ id: 'contact', label: t.sections.contact },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{t.title} | Contacts</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<button
|
||||
type="button"
|
||||
class="mb-4 flex items-center gap-1 text-sm text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||
onclick={handleBack}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{t.back}
|
||||
</button>
|
||||
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{t.title}
|
||||
</h1>
|
||||
<p class="mt-1 text-gray-600 dark:text-gray-400">
|
||||
{t.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative mb-8">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder={t.searchPlaceholder}
|
||||
class="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-4 text-sm text-gray-900 placeholder-gray-500 transition-colors focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="-mb-px flex space-x-4 overflow-x-auto" aria-label="Help sections">
|
||||
{#each sections as section (section.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium transition-colors"
|
||||
class:border-blue-500={activeSection === section.id}
|
||||
class:text-blue-600={activeSection === section.id}
|
||||
class:dark:text-blue-400={activeSection === section.id}
|
||||
class:border-transparent={activeSection !== section.id}
|
||||
class:text-gray-500={activeSection !== section.id}
|
||||
class:hover:text-gray-700={activeSection !== section.id}
|
||||
class:dark:text-gray-400={activeSection !== section.id}
|
||||
class:dark:hover:text-gray-300={activeSection !== section.id}
|
||||
onclick={() => (activeSection = section.id)}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-h-[400px]">
|
||||
<!-- FAQ Section -->
|
||||
{#if activeSection === 'faq'}
|
||||
<div class="space-y-0 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each filteredFaqs as faq (faq.id)}
|
||||
<div class="py-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between py-4 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
onclick={() => toggleFaq(faq.id)}
|
||||
>
|
||||
<span class="pr-4 font-medium text-gray-900 dark:text-gray-100">
|
||||
{faq.question}
|
||||
</span>
|
||||
<span
|
||||
class="flex-shrink-0 text-gray-500 transition-transform duration-200 dark:text-gray-400"
|
||||
class:rotate-180={expandedFaqId === faq.id}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if expandedFaqId === faq.id}
|
||||
<div class="pb-4 text-gray-600 dark:text-gray-300">
|
||||
{faq.answer}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if filteredFaqs.length === 0}
|
||||
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{$locale === 'de' ? 'Keine Ergebnisse gefunden' : 'No results found'}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Features Section -->
|
||||
{#if activeSection === 'features'}
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each features as feature}
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<span class="text-2xl">{feature.icon}</span>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{feature.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="mb-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{feature.description}
|
||||
</p>
|
||||
|
||||
<ul class="space-y-1">
|
||||
{#each feature.highlights as highlight}
|
||||
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 flex-shrink-0 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{highlight}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Shortcuts Section -->
|
||||
{#if activeSection === 'shortcuts'}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100">Shortcut</th>
|
||||
<th class="pb-3 font-semibold text-gray-900 dark:text-gray-100">
|
||||
{$locale === 'de' ? 'Aktion' : 'Action'}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{#each shortcuts as shortcut}
|
||||
<tr>
|
||||
<td class="py-3 pr-4">
|
||||
<kbd
|
||||
class="rounded bg-gray-100 px-2 py-1 font-mono text-xs text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
>
|
||||
{shortcut.shortcut}
|
||||
</kbd>
|
||||
</td>
|
||||
<td class="py-3 text-gray-600 dark:text-gray-400">
|
||||
{shortcut.action}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Contact Section -->
|
||||
{#if activeSection === 'contact'}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{t.contactTitle}
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
{t.contactDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<a
|
||||
href="mailto:support@manacore.app"
|
||||
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-400"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{t.email}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">support@manacore.app</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 dark:border-gray-700"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{$locale === 'de' ? 'Antwortzeit' : 'Response Time'}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t.responseTime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
32
packages/shared-help-content/content/contact/de/support.md
Normal file
32
packages/shared-help-content/content/contact/de/support.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
id: contact-support
|
||||
title: Support kontaktieren
|
||||
language: de
|
||||
order: 1
|
||||
supportEmail: support@manacore.app
|
||||
responseTime: In der Regel innerhalb von 24 Stunden
|
||||
---
|
||||
|
||||
## Brauchst du Hilfe?
|
||||
|
||||
Unser Support-Team hilft dir bei allen Fragen oder Problemen.
|
||||
|
||||
### Bevor du uns kontaktierst
|
||||
|
||||
- Schau in den **FAQ**-Bereich für schnelle Antworten
|
||||
- Durchstöbere unsere **Erste Schritte** Anleitungen
|
||||
- Nutze die Suchfunktion im Hilfe-Center
|
||||
|
||||
### Kontaktmöglichkeiten
|
||||
|
||||
- **E-Mail**: Schreib uns an support@manacore.app
|
||||
- **Antwortzeit**: Wir antworten in der Regel innerhalb von 24 Stunden an Werktagen
|
||||
|
||||
### Was du angeben solltest
|
||||
|
||||
Wenn du den Support kontaktierst, gib bitte an:
|
||||
|
||||
1. Deine Konto-E-Mail-Adresse
|
||||
2. Eine klare Beschreibung deines Problems
|
||||
3. Schritte zur Reproduktion (falls zutreffend)
|
||||
4. Screenshots, wenn sie bei der Erklärung helfen
|
||||
32
packages/shared-help-content/content/contact/en/support.md
Normal file
32
packages/shared-help-content/content/contact/en/support.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
id: contact-support
|
||||
title: Contact Support
|
||||
language: en
|
||||
order: 1
|
||||
supportEmail: support@manacore.app
|
||||
responseTime: Usually within 24 hours
|
||||
---
|
||||
|
||||
## Need Help?
|
||||
|
||||
Our support team is here to help you with any questions or issues.
|
||||
|
||||
### Before Contacting Us
|
||||
|
||||
- Check the **FAQ** section for quick answers
|
||||
- Browse our **Getting Started** guides
|
||||
- Search the help center using the search bar
|
||||
|
||||
### Contact Options
|
||||
|
||||
- **Email**: Send us a message at support@manacore.app
|
||||
- **Response Time**: We typically respond within 24 hours on business days
|
||||
|
||||
### What to Include
|
||||
|
||||
When contacting support, please include:
|
||||
|
||||
1. Your account email address
|
||||
2. A clear description of your issue
|
||||
3. Steps to reproduce the problem (if applicable)
|
||||
4. Screenshots if they help explain the issue
|
||||
21
packages/shared-help-content/content/faq/de/account.md
Normal file
21
packages/shared-help-content/content/faq/de/account.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
id: faq-account-001
|
||||
question: Wie erstelle ich ein Konto?
|
||||
category: account
|
||||
order: 1
|
||||
language: de
|
||||
featured: true
|
||||
tags:
|
||||
- konto
|
||||
- registrierung
|
||||
- anmeldung
|
||||
---
|
||||
|
||||
Die Kontoerstellung ist einfach:
|
||||
|
||||
1. Klicke auf **Registrieren** auf der Anmeldeseite
|
||||
2. Gib deine E-Mail-Adresse ein und wähle ein sicheres Passwort
|
||||
3. Bestätige deine E-Mail-Adresse durch Klick auf den Link, den wir dir senden
|
||||
4. Vervollständige dein Profil
|
||||
|
||||
Du kannst dich auch mit deinem Google- oder Apple-Konto registrieren, um schneller loszulegen.
|
||||
21
packages/shared-help-content/content/faq/de/billing.md
Normal file
21
packages/shared-help-content/content/faq/de/billing.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
id: faq-billing-001
|
||||
question: Wie kann ich mein Abo kündigen?
|
||||
category: billing
|
||||
order: 1
|
||||
language: de
|
||||
featured: true
|
||||
tags:
|
||||
- abo
|
||||
- kündigung
|
||||
- abrechnung
|
||||
---
|
||||
|
||||
Du kannst dein Abo jederzeit kündigen:
|
||||
|
||||
1. Gehe zu **Einstellungen** > **Abonnement**
|
||||
2. Klicke auf **Abo verwalten**
|
||||
3. Wähle **Abo kündigen**
|
||||
4. Bestätige die Kündigung
|
||||
|
||||
Dein Abo bleibt bis zum Ende des aktuellen Abrechnungszeitraums aktiv. Nach der Kündigung erfolgen keine weiteren Abbuchungen.
|
||||
23
packages/shared-help-content/content/faq/de/privacy.md
Normal file
23
packages/shared-help-content/content/faq/de/privacy.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
id: faq-privacy-001
|
||||
question: Wie werden meine Daten geschützt?
|
||||
category: privacy
|
||||
order: 1
|
||||
language: de
|
||||
featured: true
|
||||
tags:
|
||||
- datenschutz
|
||||
- daten
|
||||
- sicherheit
|
||||
- dsgvo
|
||||
---
|
||||
|
||||
Wir nehmen deinen Datenschutz ernst:
|
||||
|
||||
- **Verschlüsselung**: Alle Daten werden bei der Übertragung (TLS) und im Ruhezustand verschlüsselt
|
||||
- **DSGVO-konform**: Wir halten uns an die EU-Datenschutzverordnung
|
||||
- **Kein Datenverkauf**: Wir verkaufen niemals deine persönlichen Daten an Dritte
|
||||
- **Datenexport**: Du kannst jederzeit alle deine Daten exportieren
|
||||
- **Kontolöschung**: Du kannst dein Konto und alle zugehörigen Daten dauerhaft löschen
|
||||
|
||||
Weitere Details findest du in unserer [Datenschutzerklärung](/privacy).
|
||||
21
packages/shared-help-content/content/faq/en/account.md
Normal file
21
packages/shared-help-content/content/faq/en/account.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
id: faq-account-001
|
||||
question: How do I create an account?
|
||||
category: account
|
||||
order: 1
|
||||
language: en
|
||||
featured: true
|
||||
tags:
|
||||
- account
|
||||
- registration
|
||||
- signup
|
||||
---
|
||||
|
||||
Creating an account is simple:
|
||||
|
||||
1. Click the **Sign Up** button on the login page
|
||||
2. Enter your email address and choose a secure password
|
||||
3. Verify your email address by clicking the link we send you
|
||||
4. Complete your profile setup
|
||||
|
||||
You can also sign up using your Google or Apple account for faster registration.
|
||||
21
packages/shared-help-content/content/faq/en/billing.md
Normal file
21
packages/shared-help-content/content/faq/en/billing.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
id: faq-billing-001
|
||||
question: How do I cancel my subscription?
|
||||
category: billing
|
||||
order: 1
|
||||
language: en
|
||||
featured: true
|
||||
tags:
|
||||
- subscription
|
||||
- cancel
|
||||
- billing
|
||||
---
|
||||
|
||||
You can cancel your subscription at any time:
|
||||
|
||||
1. Go to **Settings** > **Subscription**
|
||||
2. Click **Manage Subscription**
|
||||
3. Select **Cancel Subscription**
|
||||
4. Confirm your cancellation
|
||||
|
||||
Your subscription will remain active until the end of the current billing period. You won't be charged again after cancellation.
|
||||
23
packages/shared-help-content/content/faq/en/privacy.md
Normal file
23
packages/shared-help-content/content/faq/en/privacy.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
id: faq-privacy-001
|
||||
question: How is my data protected?
|
||||
category: privacy
|
||||
order: 1
|
||||
language: en
|
||||
featured: true
|
||||
tags:
|
||||
- privacy
|
||||
- data
|
||||
- security
|
||||
- gdpr
|
||||
---
|
||||
|
||||
We take your privacy seriously:
|
||||
|
||||
- **Encryption**: All data is encrypted in transit (TLS) and at rest
|
||||
- **GDPR Compliant**: We follow EU data protection regulations
|
||||
- **No Data Selling**: We never sell your personal data to third parties
|
||||
- **Data Export**: You can export all your data at any time
|
||||
- **Account Deletion**: You can permanently delete your account and all associated data
|
||||
|
||||
For more details, please read our [Privacy Policy](/privacy).
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
id: guide-welcome
|
||||
title: Erste Schritte
|
||||
description: Lerne die Grundlagen und starte schnell durch
|
||||
difficulty: beginner
|
||||
estimatedTime: 5 Minuten
|
||||
order: 1
|
||||
language: de
|
||||
---
|
||||
|
||||
## Konto erstellen
|
||||
|
||||
Beginne mit der Erstellung deines kostenlosen Kontos. Du kannst dich mit deiner E-Mail-Adresse registrieren oder Google/Apple für eine schnellere Anmeldung nutzen.
|
||||
|
||||
## Dashboard erkunden
|
||||
|
||||
Nach dem Einloggen siehst du dein Dashboard. Dies ist deine Zentrale, von der aus du auf alle Funktionen zugreifen und wichtige Informationen auf einen Blick sehen kannst.
|
||||
|
||||
## Einstellungen anpassen
|
||||
|
||||
Besuche die Einstellungen, um dein Erlebnis zu personalisieren. Du kannst anpassen:
|
||||
|
||||
- **Design**: Wähle zwischen hell, dunkel oder Systemmodus
|
||||
- **Sprache**: Wähle deine bevorzugte Sprache
|
||||
- **Benachrichtigungen**: Konfiguriere, wie du benachrichtigt werden möchtest
|
||||
|
||||
## Hilfe jederzeit verfügbar
|
||||
|
||||
Wenn du Hilfe brauchst, klicke auf das Hilfe-Symbol oder besuche den Hilfe-Bereich. Du kannst auch unser Support-Team kontaktieren, wenn du Fragen hast.
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
id: guide-welcome
|
||||
title: Getting Started
|
||||
description: Learn the basics and get up and running quickly
|
||||
difficulty: beginner
|
||||
estimatedTime: 5 minutes
|
||||
order: 1
|
||||
language: en
|
||||
---
|
||||
|
||||
## Create Your Account
|
||||
|
||||
Start by creating your free account. You can sign up with your email address or use Google/Apple sign-in for a faster setup.
|
||||
|
||||
## Explore the Dashboard
|
||||
|
||||
After logging in, you'll see your dashboard. This is your home base where you can access all features and see important information at a glance.
|
||||
|
||||
## Customize Your Settings
|
||||
|
||||
Visit the Settings page to personalize your experience. You can adjust:
|
||||
|
||||
- **Theme**: Choose between light, dark, or system mode
|
||||
- **Language**: Select your preferred language
|
||||
- **Notifications**: Configure how you want to be notified
|
||||
|
||||
## Get Help Anytime
|
||||
|
||||
If you need assistance, click the help icon or visit the Help section. You can also reach out to our support team if you have questions.
|
||||
44
packages/shared-help-content/package.json
Normal file
44
packages/shared-help-content/package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "@manacore/shared-help-content",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./loader": {
|
||||
"types": "./src/loader.ts",
|
||||
"default": "./src/loader.ts"
|
||||
},
|
||||
"./parser": {
|
||||
"types": "./src/parser.ts",
|
||||
"default": "./src/parser.ts"
|
||||
},
|
||||
"./search": {
|
||||
"types": "./src/search.ts",
|
||||
"default": "./src/search.ts"
|
||||
},
|
||||
"./merger": {
|
||||
"types": "./src/merger.ts",
|
||||
"default": "./src/merger.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-help-types": "workspace:*",
|
||||
"fuse.js": "^7.0.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"marked": "^15.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.3",
|
||||
"zod": "^3.24.1"
|
||||
}
|
||||
}
|
||||
52
packages/shared-help-content/src/index.ts
Normal file
52
packages/shared-help-content/src/index.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* @manacore/shared-help-content
|
||||
* Central help content and utilities for loading, parsing, and searching
|
||||
*/
|
||||
|
||||
// Parser utilities
|
||||
export {
|
||||
parseMarkdown,
|
||||
parseMarkdownFiles,
|
||||
stripHtml,
|
||||
generateExcerpt,
|
||||
type ParsedContent,
|
||||
type ParseOptions,
|
||||
} from './parser.js';
|
||||
|
||||
// Content loader
|
||||
export {
|
||||
parseFAQContent,
|
||||
parseFeatureContent,
|
||||
parseShortcutsContent,
|
||||
parseGettingStartedContent,
|
||||
parseChangelogContent,
|
||||
parseContactContent,
|
||||
loadHelpContentFromFiles,
|
||||
type LoaderOptions,
|
||||
} from './loader.js';
|
||||
|
||||
// Content merger
|
||||
export { mergeContent, createEmptyContent } from './merger.js';
|
||||
|
||||
// Search functionality
|
||||
export { buildSearchIndex, search, createSearcher, flattenContentForSearch } from './search.js';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
HelpContent,
|
||||
FAQItem,
|
||||
FeatureItem,
|
||||
ShortcutsItem,
|
||||
GettingStartedItem,
|
||||
ChangelogItem,
|
||||
ContactInfo,
|
||||
SupportedLanguage,
|
||||
MergeContentOptions,
|
||||
} from '@manacore/shared-help-types';
|
||||
|
||||
export type {
|
||||
SearchResult,
|
||||
SearchOptions,
|
||||
SearchIndexConfig,
|
||||
SearchableItem,
|
||||
} from '@manacore/shared-help-types';
|
||||
292
packages/shared-help-content/src/loader.ts
Normal file
292
packages/shared-help-content/src/loader.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
/**
|
||||
* Content Loader
|
||||
* Utilities for loading help content from various sources
|
||||
*/
|
||||
|
||||
import type {
|
||||
HelpContent,
|
||||
FAQItem,
|
||||
FeatureItem,
|
||||
ShortcutsItem,
|
||||
GettingStartedItem,
|
||||
ChangelogItem,
|
||||
ContactInfo,
|
||||
SupportedLanguage,
|
||||
} from '@manacore/shared-help-types';
|
||||
import {
|
||||
faqFrontmatterSchema,
|
||||
featureFrontmatterSchema,
|
||||
shortcutsFrontmatterSchema,
|
||||
gettingStartedFrontmatterSchema,
|
||||
changelogFrontmatterSchema,
|
||||
contactFrontmatterSchema,
|
||||
} from '@manacore/shared-help-types';
|
||||
import { parseMarkdown } from './parser.js';
|
||||
import { createEmptyContent } from './merger.js';
|
||||
|
||||
export interface LoaderOptions {
|
||||
/** Locale to load */
|
||||
locale: SupportedLanguage;
|
||||
/** Fallback locale if content not found */
|
||||
fallbackLocale?: SupportedLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse FAQ content from raw Markdown
|
||||
*/
|
||||
export function parseFAQContent(rawContent: string): FAQItem {
|
||||
const parsed = parseMarkdown(rawContent, faqFrontmatterSchema);
|
||||
const fm = parsed.frontmatter as Record<string, unknown>;
|
||||
return {
|
||||
id: fm.id as string,
|
||||
language: fm.language as SupportedLanguage,
|
||||
order: fm.order as number | undefined,
|
||||
appSpecific: fm.appSpecific as boolean | undefined,
|
||||
apps: fm.apps as string[] | undefined,
|
||||
lastUpdated: fm.lastUpdated as Date | undefined,
|
||||
question: fm.question as string,
|
||||
category: fm.category as FAQItem['category'],
|
||||
featured: fm.featured as boolean | undefined,
|
||||
tags: fm.tags as string[] | undefined,
|
||||
relatedFaqs: fm.relatedFaqs as string[] | undefined,
|
||||
answer: parsed.html,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Feature content from raw Markdown
|
||||
*/
|
||||
export function parseFeatureContent(rawContent: string): FeatureItem {
|
||||
const parsed = parseMarkdown(rawContent, featureFrontmatterSchema);
|
||||
const fm = parsed.frontmatter as Record<string, unknown>;
|
||||
return {
|
||||
id: fm.id as string,
|
||||
language: fm.language as SupportedLanguage,
|
||||
order: fm.order as number | undefined,
|
||||
appSpecific: fm.appSpecific as boolean | undefined,
|
||||
apps: fm.apps as string[] | undefined,
|
||||
lastUpdated: fm.lastUpdated as Date | undefined,
|
||||
title: fm.title as string,
|
||||
description: fm.description as string,
|
||||
icon: fm.icon as string | undefined,
|
||||
category: fm.category as FeatureItem['category'],
|
||||
available: fm.available as boolean | undefined,
|
||||
comingSoon: fm.comingSoon as boolean | undefined,
|
||||
highlights: fm.highlights as string[] | undefined,
|
||||
learnMoreUrl: fm.learnMoreUrl as string | undefined,
|
||||
content: parsed.html,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Shortcuts content from raw Markdown
|
||||
*/
|
||||
export function parseShortcutsContent(rawContent: string): ShortcutsItem {
|
||||
const parsed = parseMarkdown(rawContent, shortcutsFrontmatterSchema);
|
||||
const fm = parsed.frontmatter as Record<string, unknown>;
|
||||
|
||||
// Parse markdown table to extract shortcuts
|
||||
const shortcuts = parseShortcutsTable(parsed.content);
|
||||
|
||||
return {
|
||||
id: fm.id as string,
|
||||
language: fm.language as SupportedLanguage,
|
||||
order: fm.order as number | undefined,
|
||||
appSpecific: fm.appSpecific as boolean | undefined,
|
||||
apps: fm.apps as string[] | undefined,
|
||||
lastUpdated: fm.lastUpdated as Date | undefined,
|
||||
category: fm.category as ShortcutsItem['category'],
|
||||
title: fm.title as string | undefined,
|
||||
shortcuts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a markdown table into keyboard shortcuts
|
||||
*/
|
||||
function parseShortcutsTable(
|
||||
content: string
|
||||
): Array<{ shortcut: string; action: string; description?: string }> {
|
||||
const shortcuts: Array<{ shortcut: string; action: string; description?: string }> = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
let inTable = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip header separator
|
||||
if (trimmed.match(/^\|[-:\s|]+\|$/)) {
|
||||
inTable = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse table row
|
||||
if (inTable && trimmed.startsWith('|') && trimmed.endsWith('|')) {
|
||||
const cells = trimmed
|
||||
.slice(1, -1)
|
||||
.split('|')
|
||||
.map((cell) => cell.trim());
|
||||
|
||||
if (cells.length >= 2) {
|
||||
shortcuts.push({
|
||||
shortcut: cells[0],
|
||||
action: cells[1],
|
||||
description: cells[2] || undefined,
|
||||
});
|
||||
}
|
||||
} else if (inTable && !trimmed.startsWith('|')) {
|
||||
// End of table
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return shortcuts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Getting Started guide content from raw Markdown
|
||||
*/
|
||||
export function parseGettingStartedContent(rawContent: string): GettingStartedItem {
|
||||
const parsed = parseMarkdown(rawContent, gettingStartedFrontmatterSchema);
|
||||
const fm = parsed.frontmatter as Record<string, unknown>;
|
||||
|
||||
// Extract steps from content (h2 headers)
|
||||
const steps = parseGuideSteps(parsed.content);
|
||||
|
||||
return {
|
||||
id: fm.id as string,
|
||||
language: fm.language as SupportedLanguage,
|
||||
order: fm.order as number | undefined,
|
||||
appSpecific: fm.appSpecific as boolean | undefined,
|
||||
apps: fm.apps as string[] | undefined,
|
||||
lastUpdated: fm.lastUpdated as Date | undefined,
|
||||
title: fm.title as string,
|
||||
description: fm.description as string,
|
||||
difficulty: fm.difficulty as GettingStartedItem['difficulty'],
|
||||
estimatedTime: fm.estimatedTime as string | undefined,
|
||||
prerequisites: fm.prerequisites as string[] | undefined,
|
||||
content: parsed.html,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse guide steps from markdown content (h2 headers)
|
||||
*/
|
||||
function parseGuideSteps(content: string): Array<{ title: string; content: string }> {
|
||||
const steps: Array<{ title: string; content: string }> = [];
|
||||
const sections = content.split(/^## /m);
|
||||
|
||||
for (let i = 1; i < sections.length; i++) {
|
||||
const section = sections[i];
|
||||
const newlineIndex = section.indexOf('\n');
|
||||
const title = section.substring(0, newlineIndex).trim();
|
||||
const stepContent = section.substring(newlineIndex + 1).trim();
|
||||
|
||||
steps.push({ title, content: stepContent });
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Changelog content from raw Markdown
|
||||
*/
|
||||
export function parseChangelogContent(rawContent: string): ChangelogItem {
|
||||
const parsed = parseMarkdown(rawContent, changelogFrontmatterSchema);
|
||||
const fm = parsed.frontmatter as Record<string, unknown>;
|
||||
return {
|
||||
id: fm.id as string,
|
||||
language: fm.language as SupportedLanguage,
|
||||
order: fm.order as number | undefined,
|
||||
appSpecific: fm.appSpecific as boolean | undefined,
|
||||
apps: fm.apps as string[] | undefined,
|
||||
lastUpdated: fm.lastUpdated as Date | undefined,
|
||||
version: fm.version as string,
|
||||
title: fm.title as string,
|
||||
releaseDate: fm.releaseDate as Date,
|
||||
type: fm.type as ChangelogItem['type'],
|
||||
summary: fm.summary as string | undefined,
|
||||
highlighted: fm.highlighted as boolean | undefined,
|
||||
changes: fm.changes as ChangelogItem['changes'],
|
||||
platforms: fm.platforms as string[] | undefined,
|
||||
content: parsed.html,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Contact content from raw Markdown
|
||||
*/
|
||||
export function parseContactContent(rawContent: string): ContactInfo {
|
||||
const parsed = parseMarkdown(rawContent, contactFrontmatterSchema);
|
||||
const fm = parsed.frontmatter as Record<string, unknown>;
|
||||
return {
|
||||
id: fm.id as string,
|
||||
language: fm.language as SupportedLanguage,
|
||||
order: fm.order as number | undefined,
|
||||
appSpecific: fm.appSpecific as boolean | undefined,
|
||||
apps: fm.apps as string[] | undefined,
|
||||
lastUpdated: fm.lastUpdated as Date | undefined,
|
||||
title: fm.title as string,
|
||||
supportEmail: fm.supportEmail as string | undefined,
|
||||
supportUrl: fm.supportUrl as string | undefined,
|
||||
discordUrl: fm.discordUrl as string | undefined,
|
||||
twitterUrl: fm.twitterUrl as string | undefined,
|
||||
documentationUrl: fm.documentationUrl as string | undefined,
|
||||
responseTime: fm.responseTime as string | undefined,
|
||||
content: parsed.html,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load help content from a map of file paths to content
|
||||
* This is the main entry point for content loading
|
||||
*/
|
||||
export function loadHelpContentFromFiles(
|
||||
files: Record<string, string>,
|
||||
options: LoaderOptions
|
||||
): HelpContent {
|
||||
const content = createEmptyContent();
|
||||
const { locale, fallbackLocale = 'en' } = options;
|
||||
|
||||
for (const [path, rawContent] of Object.entries(files)) {
|
||||
try {
|
||||
// Determine content type from path
|
||||
if (path.includes('/faq/')) {
|
||||
const faq = parseFAQContent(rawContent);
|
||||
if (faq.language === locale || faq.language === fallbackLocale) {
|
||||
content.faq.push(faq);
|
||||
}
|
||||
} else if (path.includes('/features/')) {
|
||||
const feature = parseFeatureContent(rawContent);
|
||||
if (feature.language === locale || feature.language === fallbackLocale) {
|
||||
content.features.push(feature);
|
||||
}
|
||||
} else if (path.includes('/shortcuts/')) {
|
||||
const shortcuts = parseShortcutsContent(rawContent);
|
||||
if (shortcuts.language === locale || shortcuts.language === fallbackLocale) {
|
||||
content.shortcuts.push(shortcuts);
|
||||
}
|
||||
} else if (path.includes('/getting-started/')) {
|
||||
const guide = parseGettingStartedContent(rawContent);
|
||||
if (guide.language === locale || guide.language === fallbackLocale) {
|
||||
content.gettingStarted.push(guide);
|
||||
}
|
||||
} else if (path.includes('/changelog/')) {
|
||||
const changelog = parseChangelogContent(rawContent);
|
||||
if (changelog.language === locale || changelog.language === fallbackLocale) {
|
||||
content.changelog.push(changelog);
|
||||
}
|
||||
} else if (path.includes('/contact/')) {
|
||||
const contact = parseContactContent(rawContent);
|
||||
if (contact.language === locale || contact.language === fallbackLocale) {
|
||||
content.contact = contact;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip files that fail to parse
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
119
packages/shared-help-content/src/merger.ts
Normal file
119
packages/shared-help-content/src/merger.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Content Merger
|
||||
* Merges central help content with app-specific content
|
||||
*/
|
||||
|
||||
import type { HelpContent, MergeContentOptions } from '@manacore/shared-help-types';
|
||||
|
||||
/**
|
||||
* Filter content items by locale and app
|
||||
*/
|
||||
function filterItems<T extends { language: string; appSpecific?: boolean; apps?: string[] }>(
|
||||
items: T[],
|
||||
locale: string,
|
||||
appId: string
|
||||
): T[] {
|
||||
return items.filter((item) => {
|
||||
// Filter by language
|
||||
if (item.language !== locale) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Include non-app-specific items
|
||||
if (!item.appSpecific) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Include app-specific items for this app
|
||||
return item.apps?.includes(appId) ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two arrays, optionally replacing items with same ID
|
||||
*/
|
||||
function mergeArrays<T extends { id: string }>(
|
||||
central: T[],
|
||||
appSpecific: T[],
|
||||
overrideById: boolean
|
||||
): T[] {
|
||||
if (!overrideById) {
|
||||
return [...central, ...appSpecific];
|
||||
}
|
||||
|
||||
const appIds = new Set(appSpecific.map((item) => item.id));
|
||||
const filtered = central.filter((item) => !appIds.has(item.id));
|
||||
return [...filtered, ...appSpecific];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort items by order property
|
||||
*/
|
||||
function sortByOrder<T extends { order?: number }>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge central help content with app-specific content
|
||||
*/
|
||||
export function mergeContent(
|
||||
central: HelpContent,
|
||||
appSpecific: Partial<HelpContent>,
|
||||
options: MergeContentOptions
|
||||
): HelpContent {
|
||||
const { appId, locale, overrideById = true } = options;
|
||||
|
||||
// Filter central content by locale and app
|
||||
const filteredCentral: HelpContent = {
|
||||
faq: filterItems(central.faq, locale, appId),
|
||||
features: filterItems(central.features, locale, appId),
|
||||
shortcuts: filterItems(central.shortcuts, locale, appId),
|
||||
gettingStarted: filterItems(central.gettingStarted, locale, appId),
|
||||
changelog: filterItems(central.changelog, locale, appId),
|
||||
contact: central.contact?.language === locale ? central.contact : null,
|
||||
};
|
||||
|
||||
// Filter app-specific content
|
||||
const filteredApp: Partial<HelpContent> = {
|
||||
faq: appSpecific.faq ? filterItems(appSpecific.faq, locale, appId) : [],
|
||||
features: appSpecific.features ? filterItems(appSpecific.features, locale, appId) : [],
|
||||
shortcuts: appSpecific.shortcuts ? filterItems(appSpecific.shortcuts, locale, appId) : [],
|
||||
gettingStarted: appSpecific.gettingStarted
|
||||
? filterItems(appSpecific.gettingStarted, locale, appId)
|
||||
: [],
|
||||
changelog: appSpecific.changelog ? filterItems(appSpecific.changelog, locale, appId) : [],
|
||||
contact: appSpecific.contact?.language === locale ? appSpecific.contact : null,
|
||||
};
|
||||
|
||||
// Merge and sort
|
||||
return {
|
||||
faq: sortByOrder(mergeArrays(filteredCentral.faq, filteredApp.faq ?? [], overrideById)),
|
||||
features: sortByOrder(
|
||||
mergeArrays(filteredCentral.features, filteredApp.features ?? [], overrideById)
|
||||
),
|
||||
shortcuts: sortByOrder(
|
||||
mergeArrays(filteredCentral.shortcuts, filteredApp.shortcuts ?? [], overrideById)
|
||||
),
|
||||
gettingStarted: sortByOrder(
|
||||
mergeArrays(filteredCentral.gettingStarted, filteredApp.gettingStarted ?? [], overrideById)
|
||||
),
|
||||
changelog: sortByOrder(
|
||||
mergeArrays(filteredCentral.changelog, filteredApp.changelog ?? [], overrideById)
|
||||
),
|
||||
contact: filteredApp.contact ?? filteredCentral.contact,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty HelpContent object
|
||||
*/
|
||||
export function createEmptyContent(): HelpContent {
|
||||
return {
|
||||
faq: [],
|
||||
features: [],
|
||||
shortcuts: [],
|
||||
gettingStarted: [],
|
||||
changelog: [],
|
||||
contact: null,
|
||||
};
|
||||
}
|
||||
86
packages/shared-help-content/src/parser.ts
Normal file
86
packages/shared-help-content/src/parser.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Markdown + Frontmatter Parser
|
||||
* Parses Markdown files with YAML frontmatter
|
||||
*/
|
||||
|
||||
import matter from 'gray-matter';
|
||||
import { marked } from 'marked';
|
||||
import type { ZodSchema } from 'zod';
|
||||
|
||||
export interface ParsedContent<T> {
|
||||
frontmatter: T;
|
||||
content: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface ParseOptions {
|
||||
/** Convert Markdown to HTML */
|
||||
renderHtml?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Markdown file with frontmatter
|
||||
*/
|
||||
export function parseMarkdown<T>(
|
||||
rawContent: string,
|
||||
schema?: ZodSchema<T>,
|
||||
options: ParseOptions = { renderHtml: true }
|
||||
): ParsedContent<T> {
|
||||
const { data, content } = matter(rawContent);
|
||||
|
||||
// Validate frontmatter if schema provided
|
||||
let frontmatter: T;
|
||||
if (schema) {
|
||||
const result = schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid frontmatter: ${result.error.message}`);
|
||||
}
|
||||
frontmatter = result.data;
|
||||
} else {
|
||||
frontmatter = data as T;
|
||||
}
|
||||
|
||||
// Render HTML if requested
|
||||
const html = options.renderHtml ? (marked.parse(content) as string) : '';
|
||||
|
||||
return {
|
||||
frontmatter,
|
||||
content: content.trim(),
|
||||
html,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse multiple Markdown files
|
||||
*/
|
||||
export function parseMarkdownFiles<T>(
|
||||
files: { filename: string; content: string }[],
|
||||
schema?: ZodSchema<T>,
|
||||
options?: ParseOptions
|
||||
): Array<ParsedContent<T> & { filename: string }> {
|
||||
return files.map(({ filename, content }) => ({
|
||||
filename,
|
||||
...parseMarkdown<T>(content, schema, options),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content from HTML (for search indexing)
|
||||
*/
|
||||
export function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate excerpt from content
|
||||
*/
|
||||
export function generateExcerpt(content: string, maxLength = 150): string {
|
||||
const text = stripHtml(content);
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, maxLength).trim() + '...';
|
||||
}
|
||||
209
packages/shared-help-content/src/search.ts
Normal file
209
packages/shared-help-content/src/search.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* Search Functionality using Fuse.js
|
||||
* Provides full-text search across help content
|
||||
*/
|
||||
|
||||
import Fuse, { type IFuseOptions } from 'fuse.js';
|
||||
import type {
|
||||
HelpContent,
|
||||
FAQItem,
|
||||
FeatureItem,
|
||||
GettingStartedItem,
|
||||
ChangelogItem,
|
||||
} from '@manacore/shared-help-types';
|
||||
import type {
|
||||
SearchableItem,
|
||||
SearchResult,
|
||||
SearchOptions,
|
||||
SearchIndexConfig,
|
||||
} from '@manacore/shared-help-types';
|
||||
import { generateExcerpt, stripHtml } from './parser.js';
|
||||
|
||||
const DEFAULT_CONFIG: SearchIndexConfig = {
|
||||
titleWeight: 2,
|
||||
contentWeight: 1,
|
||||
tagsWeight: 1.5,
|
||||
threshold: 0.3,
|
||||
minMatchCharLength: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert HelpContent to searchable items
|
||||
*/
|
||||
export function flattenContentForSearch(content: HelpContent): SearchableItem[] {
|
||||
const items: SearchableItem[] = [];
|
||||
|
||||
// FAQs
|
||||
for (const faq of content.faq) {
|
||||
items.push({
|
||||
id: faq.id,
|
||||
type: 'faq',
|
||||
title: faq.question,
|
||||
question: faq.question,
|
||||
content: stripHtml(faq.answer),
|
||||
tags: faq.tags,
|
||||
});
|
||||
}
|
||||
|
||||
// Features
|
||||
for (const feature of content.features) {
|
||||
items.push({
|
||||
id: feature.id,
|
||||
type: 'feature',
|
||||
title: feature.title,
|
||||
description: feature.description,
|
||||
content: stripHtml(feature.content),
|
||||
tags: feature.highlights,
|
||||
});
|
||||
}
|
||||
|
||||
// Getting Started Guides
|
||||
for (const guide of content.gettingStarted) {
|
||||
items.push({
|
||||
id: guide.id,
|
||||
type: 'guide',
|
||||
title: guide.title,
|
||||
description: guide.description,
|
||||
content: stripHtml(guide.content),
|
||||
});
|
||||
}
|
||||
|
||||
// Changelog
|
||||
for (const log of content.changelog) {
|
||||
items.push({
|
||||
id: log.id,
|
||||
type: 'changelog',
|
||||
title: `${log.version} - ${log.title}`,
|
||||
content: stripHtml(log.content),
|
||||
description: log.summary,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Fuse.js search index from help content
|
||||
*/
|
||||
export function buildSearchIndex(
|
||||
content: HelpContent,
|
||||
config: SearchIndexConfig = DEFAULT_CONFIG
|
||||
): Fuse<SearchableItem> {
|
||||
const items = flattenContentForSearch(content);
|
||||
|
||||
const fuseOptions: IFuseOptions<SearchableItem> = {
|
||||
keys: [
|
||||
{ name: 'title', weight: config.titleWeight ?? 2 },
|
||||
{ name: 'question', weight: config.titleWeight ?? 2 },
|
||||
{ name: 'content', weight: config.contentWeight ?? 1 },
|
||||
{ name: 'description', weight: config.contentWeight ?? 1 },
|
||||
{ name: 'tags', weight: config.tagsWeight ?? 1.5 },
|
||||
],
|
||||
threshold: config.threshold ?? 0.3,
|
||||
includeScore: true,
|
||||
minMatchCharLength: config.minMatchCharLength ?? 2,
|
||||
ignoreLocation: true,
|
||||
};
|
||||
|
||||
return new Fuse(items, fuseOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the original item from content
|
||||
*/
|
||||
function findOriginalItem(
|
||||
id: string,
|
||||
type: string,
|
||||
content: HelpContent
|
||||
): FAQItem | FeatureItem | GettingStartedItem | ChangelogItem | null {
|
||||
switch (type) {
|
||||
case 'faq':
|
||||
return content.faq.find((item) => item.id === id) ?? null;
|
||||
case 'feature':
|
||||
return content.features.find((item) => item.id === id) ?? null;
|
||||
case 'guide':
|
||||
return content.gettingStarted.find((item) => item.id === id) ?? null;
|
||||
case 'changelog':
|
||||
return content.changelog.find((item) => item.id === id) ?? null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight matching text in content
|
||||
*/
|
||||
function highlightMatch(text: string, query: string): string {
|
||||
if (!query.trim()) return text;
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
||||
return text.replace(regex, '<mark>$1</mark>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search help content
|
||||
*/
|
||||
export function search(
|
||||
index: Fuse<SearchableItem>,
|
||||
query: string,
|
||||
content: HelpContent,
|
||||
options: SearchOptions = {}
|
||||
): SearchResult[] {
|
||||
const { limit = 10, threshold, types, appId } = options;
|
||||
|
||||
if (!query.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let results = index.search(query, { limit: limit * 2 });
|
||||
|
||||
// Filter by type if specified
|
||||
if (types && types.length > 0) {
|
||||
results = results.filter((r) => types.includes(r.item.type));
|
||||
}
|
||||
|
||||
// Filter by app if specified
|
||||
if (appId) {
|
||||
results = results.filter((r) => {
|
||||
const originalItem = findOriginalItem(r.item.id, r.item.type, content);
|
||||
if (!originalItem) return true;
|
||||
if (!originalItem.appSpecific) return true;
|
||||
return originalItem.apps?.includes(appId);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply threshold filter if specified
|
||||
if (threshold !== undefined) {
|
||||
results = results.filter((r) => (r.score ?? 1) <= threshold);
|
||||
}
|
||||
|
||||
// Limit results
|
||||
results = results.slice(0, limit);
|
||||
|
||||
const mappedResults: SearchResult[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
const originalItem = findOriginalItem(result.item.id, result.item.type, content);
|
||||
if (!originalItem) continue;
|
||||
|
||||
mappedResults.push({
|
||||
id: result.item.id,
|
||||
type: result.item.type,
|
||||
title: result.item.title,
|
||||
excerpt: generateExcerpt(result.item.content, 150),
|
||||
score: result.score ?? 1,
|
||||
highlight: highlightMatch(result.item.title, query),
|
||||
item: originalItem,
|
||||
});
|
||||
}
|
||||
|
||||
return mappedResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a search function with pre-built index
|
||||
*/
|
||||
export function createSearcher(content: HelpContent, config?: SearchIndexConfig) {
|
||||
const index = buildSearchIndex(content, config);
|
||||
|
||||
return (query: string, options?: SearchOptions) => search(index, query, content, options);
|
||||
}
|
||||
16
packages/shared-help-content/tsconfig.json
Normal file
16
packages/shared-help-content/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
24
packages/shared-help-mobile/package.json
Normal file
24
packages/shared-help-mobile/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@manacore/shared-help-mobile",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"type-check": "echo 'Skipping type-check: @manacore/shared-help-mobile requires React Native environment'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-help-types": "workspace:*",
|
||||
"@manacore/shared-help-content": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~18.3.12",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": ">=52.0.0",
|
||||
"nativewind": "^4.0.0",
|
||||
"react": "18.3.1",
|
||||
"react-native": ">=0.76.0"
|
||||
}
|
||||
}
|
||||
46
packages/shared-help-mobile/src/components/CategoryTabs.tsx
Normal file
46
packages/shared-help-mobile/src/components/CategoryTabs.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Category Tabs component for mobile Help screen
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text, TouchableOpacity, ScrollView } from 'react-native';
|
||||
import type { HelpSection } from '../types';
|
||||
|
||||
interface CategoryTabsProps {
|
||||
sections: Array<{ id: HelpSection; label: string; show: boolean }>;
|
||||
activeSection: HelpSection;
|
||||
onSectionChange: (section: HelpSection) => void;
|
||||
}
|
||||
|
||||
export function CategoryTabs({ sections, activeSection, onSectionChange }: CategoryTabsProps) {
|
||||
const visibleSections = sections.filter((s) => s.show);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
className="mb-4"
|
||||
contentContainerStyle={{ paddingHorizontal: 4 }}
|
||||
>
|
||||
{visibleSections.map((section) => (
|
||||
<TouchableOpacity
|
||||
key={section.id}
|
||||
onPress={() => onSectionChange(section.id)}
|
||||
className={`px-4 py-2 mr-2 rounded-full ${
|
||||
activeSection === section.id
|
||||
? 'bg-blue-500 dark:bg-blue-600'
|
||||
: 'bg-gray-100 dark:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-sm font-medium ${
|
||||
activeSection === section.id ? 'text-white' : 'text-gray-600 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{section.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
71
packages/shared-help-mobile/src/components/ContactCard.tsx
Normal file
71
packages/shared-help-mobile/src/components/ContactCard.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Contact Card component for mobile
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, Linking } from 'react-native';
|
||||
import type { ContactInfo } from '@manacore/shared-help-types';
|
||||
import type { HelpTranslations } from '../types';
|
||||
|
||||
interface ContactCardProps {
|
||||
contact: ContactInfo | null;
|
||||
translations: Pick<HelpTranslations, 'contact'>;
|
||||
}
|
||||
|
||||
export function ContactCard({ contact, translations }: ContactCardProps) {
|
||||
if (!contact) {
|
||||
return (
|
||||
<View className="py-8 items-center">
|
||||
<Text className="text-gray-500 dark:text-gray-400">{translations.contact.noInfo}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function handleEmailPress() {
|
||||
if (contact.supportEmail) {
|
||||
Linking.openURL(`mailto:${contact.supportEmail}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Strip HTML tags for mobile display
|
||||
const plainContent = contact.content.replace(/<[^>]*>/g, '').trim();
|
||||
|
||||
return (
|
||||
<View>
|
||||
{plainContent && (
|
||||
<Text className="text-gray-600 dark:text-gray-300 text-sm leading-relaxed mb-4">
|
||||
{plainContent}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{contact.supportEmail && (
|
||||
<TouchableOpacity
|
||||
onPress={handleEmailPress}
|
||||
className="flex-row items-center bg-white dark:bg-gray-800 rounded-xl p-4 mb-3 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<View className="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 items-center justify-center mr-3">
|
||||
<Text>✉️</Text>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{translations.contact.email}
|
||||
</Text>
|
||||
<Text className="text-sm text-gray-600 dark:text-gray-400">{contact.supportEmail}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{contact.responseTime && (
|
||||
<View className="flex-row items-center bg-white dark:bg-gray-800 rounded-xl p-4 border border-gray-200 dark:border-gray-700">
|
||||
<View className="w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30 items-center justify-center mr-3">
|
||||
<Text>⏱️</Text>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="font-medium text-gray-900 dark:text-gray-100">Response Time</Text>
|
||||
<Text className="text-sm text-gray-600 dark:text-gray-400">{contact.responseTime}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
57
packages/shared-help-mobile/src/components/FAQItem.tsx
Normal file
57
packages/shared-help-mobile/src/components/FAQItem.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Expandable FAQ Item component for mobile
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, LayoutAnimation, Platform, UIManager } from 'react-native';
|
||||
import type { FAQItem as FAQItemType } from '@manacore/shared-help-types';
|
||||
|
||||
// Enable LayoutAnimation on Android
|
||||
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
|
||||
UIManager.setLayoutAnimationEnabledExperimental(true);
|
||||
}
|
||||
|
||||
interface FAQItemProps {
|
||||
item: FAQItemType;
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
export function FAQItem({ item, expanded = false, onToggle }: FAQItemProps) {
|
||||
function handlePress() {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
||||
onToggle?.();
|
||||
}
|
||||
|
||||
// Strip HTML tags for mobile display
|
||||
const plainAnswer = item.answer.replace(/<[^>]*>/g, '').trim();
|
||||
|
||||
return (
|
||||
<View className="border-b border-gray-200 dark:border-gray-700">
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
className="py-4 flex-row items-center justify-between"
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded }}
|
||||
>
|
||||
<Text className="flex-1 pr-4 font-medium text-gray-900 dark:text-gray-100 text-base">
|
||||
{item.question}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-gray-500 dark:text-gray-400"
|
||||
style={{ transform: [{ rotate: expanded ? '180deg' : '0deg' }] }}
|
||||
>
|
||||
▼
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{expanded && (
|
||||
<View className="pb-4">
|
||||
<Text className="text-gray-600 dark:text-gray-300 text-sm leading-relaxed">
|
||||
{plainAnswer}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
39
packages/shared-help-mobile/src/components/FAQList.tsx
Normal file
39
packages/shared-help-mobile/src/components/FAQList.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* FAQ List component for mobile
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import { FAQItem } from './FAQItem';
|
||||
import type { FAQListProps } from '../types';
|
||||
|
||||
export function FAQList({ items, translations }: FAQListProps) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(
|
||||
items.length > 0 ? items[0].id : null
|
||||
);
|
||||
|
||||
function toggleItem(id: string) {
|
||||
setExpandedId(expandedId === id ? null : id);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<View className="py-8 items-center">
|
||||
<Text className="text-gray-500 dark:text-gray-400">{translations.faq.noItems}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
{items.map((item) => (
|
||||
<FAQItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
expanded={expandedId === item.id}
|
||||
onToggle={() => toggleItem(item.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
49
packages/shared-help-mobile/src/components/FeatureCard.tsx
Normal file
49
packages/shared-help-mobile/src/components/FeatureCard.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Feature Card component for mobile
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import type { FeatureItem } from '@manacore/shared-help-types';
|
||||
|
||||
interface FeatureCardProps {
|
||||
item: FeatureItem;
|
||||
comingSoonLabel?: string;
|
||||
}
|
||||
|
||||
export function FeatureCard({ item, comingSoonLabel = 'Coming soon' }: FeatureCardProps) {
|
||||
return (
|
||||
<View className="bg-white dark:bg-gray-800 rounded-xl p-4 mb-3 border border-gray-200 dark:border-gray-700">
|
||||
<View className="flex-row items-center mb-2">
|
||||
{item.icon && <Text className="text-2xl mr-3">{item.icon}</Text>}
|
||||
<View className="flex-1">
|
||||
<View className="flex-row items-center">
|
||||
<Text className="font-semibold text-gray-900 dark:text-gray-100 text-base">
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.comingSoon && (
|
||||
<View className="ml-2 bg-amber-100 dark:bg-amber-900/30 px-2 py-0.5 rounded-full">
|
||||
<Text className="text-xs text-amber-700 dark:text-amber-400">
|
||||
{comingSoonLabel}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text className="text-gray-600 dark:text-gray-400 text-sm mb-2">{item.description}</Text>
|
||||
|
||||
{item.highlights && item.highlights.length > 0 && (
|
||||
<View className="mt-2">
|
||||
{item.highlights.map((highlight, index) => (
|
||||
<View key={index} className="flex-row items-start mb-1">
|
||||
<Text className="text-green-500 mr-2">✓</Text>
|
||||
<Text className="text-gray-600 dark:text-gray-400 text-sm flex-1">{highlight}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
26
packages/shared-help-mobile/src/components/FeaturesList.tsx
Normal file
26
packages/shared-help-mobile/src/components/FeaturesList.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Features List component for mobile
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, Text } from 'react-native';
|
||||
import { FeatureCard } from './FeatureCard';
|
||||
import type { FeaturesListProps } from '../types';
|
||||
|
||||
export function FeaturesList({ items, translations }: FeaturesListProps) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<View className="py-8 items-center">
|
||||
<Text className="text-gray-500 dark:text-gray-400">{translations.features.noItems}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
{items.map((item) => (
|
||||
<FeatureCard key={item.id} item={item} comingSoonLabel={translations.features.comingSoon} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
42
packages/shared-help-mobile/src/components/HelpSearchBar.tsx
Normal file
42
packages/shared-help-mobile/src/components/HelpSearchBar.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Search Bar component for mobile Help screen
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { View, TextInput, TouchableOpacity, Text } from 'react-native';
|
||||
import type { HelpSearchBarProps } from '../types';
|
||||
|
||||
export function HelpSearchBar({ placeholder, onSearch, onClear }: HelpSearchBarProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
function handleChangeText(text: string) {
|
||||
setQuery(text);
|
||||
onSearch(text);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
setQuery('');
|
||||
onClear();
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center bg-gray-100 dark:bg-gray-800 rounded-xl px-4 py-3 mb-4">
|
||||
<Text className="text-gray-400 mr-2">🔍</Text>
|
||||
<TextInput
|
||||
className="flex-1 text-gray-900 dark:text-gray-100 text-base"
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#9CA3AF"
|
||||
value={query}
|
||||
onChangeText={handleChangeText}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
{query.length > 0 && (
|
||||
<TouchableOpacity onPress={handleClear} className="ml-2">
|
||||
<Text className="text-gray-400">✕</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
51
packages/shared-help-mobile/src/hooks/useHelpContent.ts
Normal file
51
packages/shared-help-mobile/src/hooks/useHelpContent.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Hook for loading and managing help content in mobile apps
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { HelpContent } from '@manacore/shared-help-types';
|
||||
import { mergeContent, createEmptyContent, createSearcher } from '@manacore/shared-help-content';
|
||||
import type { UseHelpContentOptions, UseHelpContentResult } from '../types';
|
||||
|
||||
export function useHelpContent(options: UseHelpContentOptions): UseHelpContentResult {
|
||||
const { appId, locale, centralContent, appContent } = options;
|
||||
const [loading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Merge central and app-specific content
|
||||
const content = useMemo(() => {
|
||||
try {
|
||||
const base = centralContent ?? createEmptyContent();
|
||||
if (appContent) {
|
||||
return mergeContent(base, appContent, {
|
||||
appId,
|
||||
locale,
|
||||
});
|
||||
}
|
||||
return base;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to merge content'));
|
||||
return createEmptyContent();
|
||||
}
|
||||
}, [centralContent, appContent, appId, locale]);
|
||||
|
||||
return {
|
||||
content,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for searching help content
|
||||
*/
|
||||
export function useHelpSearch(content: HelpContent) {
|
||||
const searcher = useMemo(() => createSearcher(content), [content]);
|
||||
|
||||
return {
|
||||
search: (query: string, limit?: number) => {
|
||||
if (!query.trim()) return [];
|
||||
return searcher(query, { limit: limit ?? 10 });
|
||||
},
|
||||
};
|
||||
}
|
||||
32
packages/shared-help-mobile/src/index.ts
Normal file
32
packages/shared-help-mobile/src/index.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* @manacore/shared-help-mobile
|
||||
* React Native components for the Help system
|
||||
*/
|
||||
|
||||
// Main screen
|
||||
export { HelpScreen } from './screens/HelpScreen';
|
||||
|
||||
// Components
|
||||
export { FAQList } from './components/FAQList';
|
||||
export { FAQItem } from './components/FAQItem';
|
||||
export { FeaturesList } from './components/FeaturesList';
|
||||
export { FeatureCard } from './components/FeatureCard';
|
||||
export { HelpSearchBar } from './components/HelpSearchBar';
|
||||
export { CategoryTabs } from './components/CategoryTabs';
|
||||
export { ContactCard } from './components/ContactCard';
|
||||
|
||||
// Hooks
|
||||
export { useHelpContent, useHelpSearch } from './hooks/useHelpContent';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
HelpScreenProps,
|
||||
HelpTranslations,
|
||||
HelpSection,
|
||||
UseHelpContentOptions,
|
||||
UseHelpContentResult,
|
||||
FAQListProps,
|
||||
FeaturesListProps,
|
||||
HelpSearchBarProps,
|
||||
HelpSearchResultsProps,
|
||||
} from './types';
|
||||
206
packages/shared-help-mobile/src/screens/HelpScreen.tsx
Normal file
206
packages/shared-help-mobile/src/screens/HelpScreen.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* Main Help Screen component for mobile apps
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { View, Text, ScrollView, SafeAreaView } from 'react-native';
|
||||
import type { HelpScreenProps, HelpSection } from '../types';
|
||||
import { HelpSearchBar } from '../components/HelpSearchBar';
|
||||
import { CategoryTabs } from '../components/CategoryTabs';
|
||||
import { FAQList } from '../components/FAQList';
|
||||
import { FeaturesList } from '../components/FeaturesList';
|
||||
import { ContactCard } from '../components/ContactCard';
|
||||
import { useHelpSearch } from '../hooks/useHelpContent';
|
||||
import type { SearchResult } from '@manacore/shared-help-types';
|
||||
|
||||
export function HelpScreen({
|
||||
content,
|
||||
appName,
|
||||
appId: _appId,
|
||||
translations,
|
||||
onBack: _onBack,
|
||||
defaultSection = 'faq',
|
||||
}: HelpScreenProps) {
|
||||
const [activeSection, setActiveSection] = useState<HelpSection>(defaultSection);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
|
||||
const { search } = useHelpSearch(content);
|
||||
|
||||
// Define available sections
|
||||
const sections = useMemo(
|
||||
() => [
|
||||
{ id: 'faq' as HelpSection, label: translations.sections.faq, show: content.faq.length > 0 },
|
||||
{
|
||||
id: 'features' as HelpSection,
|
||||
label: translations.sections.features,
|
||||
show: content.features.length > 0,
|
||||
},
|
||||
{
|
||||
id: 'shortcuts' as HelpSection,
|
||||
label: translations.sections.shortcuts,
|
||||
show: content.shortcuts.length > 0,
|
||||
},
|
||||
{
|
||||
id: 'getting-started' as HelpSection,
|
||||
label: translations.sections.gettingStarted,
|
||||
show: content.gettingStarted.length > 0,
|
||||
},
|
||||
{
|
||||
id: 'changelog' as HelpSection,
|
||||
label: translations.sections.changelog,
|
||||
show: content.changelog.length > 0,
|
||||
},
|
||||
{
|
||||
id: 'contact' as HelpSection,
|
||||
label: translations.sections.contact,
|
||||
show: !!content.contact,
|
||||
},
|
||||
],
|
||||
[content, translations]
|
||||
);
|
||||
|
||||
function handleSearch(query: string) {
|
||||
setSearchQuery(query);
|
||||
if (query.trim().length >= 2) {
|
||||
const results = search(query, 10);
|
||||
setSearchResults(results);
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClearSearch() {
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
}
|
||||
|
||||
function handleResultPress(result: SearchResult) {
|
||||
// Navigate to appropriate section
|
||||
switch (result.type) {
|
||||
case 'faq':
|
||||
setActiveSection('faq');
|
||||
break;
|
||||
case 'feature':
|
||||
setActiveSection('features');
|
||||
break;
|
||||
case 'guide':
|
||||
setActiveSection('getting-started');
|
||||
break;
|
||||
case 'changelog':
|
||||
setActiveSection('changelog');
|
||||
break;
|
||||
}
|
||||
handleClearSearch();
|
||||
}
|
||||
|
||||
// Use handleResultPress in search results (currently just viewing results)
|
||||
void handleResultPress;
|
||||
|
||||
function renderContent() {
|
||||
// Show search results if searching
|
||||
if (searchQuery.length >= 2) {
|
||||
if (searchResults.length === 0) {
|
||||
return (
|
||||
<View className="py-8 items-center">
|
||||
<Text className="text-gray-500 dark:text-gray-400">
|
||||
{translations.search.noResults.replace('{query}', searchQuery)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{translations.search.resultsCount.replace('{count}', String(searchResults.length))}
|
||||
</Text>
|
||||
{searchResults.map((result) => (
|
||||
<View
|
||||
key={result.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl p-4 mb-2 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<Text className="font-medium text-gray-900 dark:text-gray-100">{result.title}</Text>
|
||||
<Text className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{result.excerpt}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Show section content
|
||||
switch (activeSection) {
|
||||
case 'faq':
|
||||
return <FAQList items={content.faq} translations={translations} />;
|
||||
case 'features':
|
||||
return <FeaturesList items={content.features} translations={translations} />;
|
||||
case 'contact':
|
||||
return <ContactCard contact={content.contact} translations={translations} />;
|
||||
case 'shortcuts':
|
||||
return (
|
||||
<View className="py-8 items-center">
|
||||
<Text className="text-gray-500 dark:text-gray-400">
|
||||
{translations.shortcuts.noItems}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
case 'getting-started':
|
||||
return (
|
||||
<View className="py-8 items-center">
|
||||
<Text className="text-gray-500 dark:text-gray-400">
|
||||
{translations.gettingStarted.noItems}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
case 'changelog':
|
||||
return (
|
||||
<View className="py-8 items-center">
|
||||
<Text className="text-gray-500 dark:text-gray-400">
|
||||
{translations.changelog.noItems}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-gray-50 dark:bg-gray-900">
|
||||
<ScrollView className="flex-1 px-4 pt-4">
|
||||
{/* Header */}
|
||||
<View className="mb-6">
|
||||
<Text className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{translations.title}
|
||||
</Text>
|
||||
{translations.subtitle && (
|
||||
<Text className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{translations.subtitle} - {appName}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Search */}
|
||||
<HelpSearchBar
|
||||
placeholder={translations.searchPlaceholder}
|
||||
onSearch={handleSearch}
|
||||
onClear={handleClearSearch}
|
||||
/>
|
||||
|
||||
{/* Category Tabs */}
|
||||
{searchQuery.length < 2 && (
|
||||
<CategoryTabs
|
||||
sections={sections}
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<View className="pb-8">{renderContent()}</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
95
packages/shared-help-mobile/src/types.ts
Normal file
95
packages/shared-help-mobile/src/types.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* Mobile-specific types for Help components
|
||||
*/
|
||||
|
||||
import type { HelpContent, SearchResult, SupportedLanguage } from '@manacore/shared-help-types';
|
||||
|
||||
export type HelpSection =
|
||||
| 'faq'
|
||||
| 'features'
|
||||
| 'shortcuts'
|
||||
| 'getting-started'
|
||||
| 'changelog'
|
||||
| 'contact';
|
||||
|
||||
export interface HelpScreenProps {
|
||||
content: HelpContent;
|
||||
appName: string;
|
||||
appId: string;
|
||||
translations: HelpTranslations;
|
||||
onBack?: () => void;
|
||||
defaultSection?: HelpSection;
|
||||
}
|
||||
|
||||
export interface HelpTranslations {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
searchPlaceholder: string;
|
||||
sections: {
|
||||
faq: string;
|
||||
features: string;
|
||||
shortcuts: string;
|
||||
gettingStarted: string;
|
||||
changelog: string;
|
||||
contact: string;
|
||||
};
|
||||
search: {
|
||||
noResults: string;
|
||||
resultsCount: string;
|
||||
};
|
||||
faq: {
|
||||
noItems: string;
|
||||
};
|
||||
features: {
|
||||
noItems: string;
|
||||
comingSoon: string;
|
||||
};
|
||||
shortcuts: {
|
||||
noItems: string;
|
||||
};
|
||||
gettingStarted: {
|
||||
noItems: string;
|
||||
};
|
||||
changelog: {
|
||||
noItems: string;
|
||||
};
|
||||
contact: {
|
||||
noInfo: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseHelpContentOptions {
|
||||
appId: string;
|
||||
locale: SupportedLanguage;
|
||||
centralContent?: HelpContent;
|
||||
appContent?: Partial<HelpContent>;
|
||||
}
|
||||
|
||||
export interface UseHelpContentResult {
|
||||
content: HelpContent;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export interface FAQListProps {
|
||||
items: HelpContent['faq'];
|
||||
translations: Pick<HelpTranslations, 'faq'>;
|
||||
}
|
||||
|
||||
export interface FeaturesListProps {
|
||||
items: HelpContent['features'];
|
||||
translations: Pick<HelpTranslations, 'features'>;
|
||||
}
|
||||
|
||||
export interface HelpSearchBarProps {
|
||||
placeholder?: string;
|
||||
onSearch: (query: string) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export interface HelpSearchResultsProps {
|
||||
results: SearchResult[];
|
||||
onResultPress: (result: SearchResult) => void;
|
||||
translations: Pick<HelpTranslations, 'search'>;
|
||||
}
|
||||
17
packages/shared-help-mobile/tsconfig.json
Normal file
17
packages/shared-help-mobile/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
35
packages/shared-help-types/package.json
Normal file
35
packages/shared-help-types/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "@manacore/shared-help-types",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./content": {
|
||||
"types": "./src/content.ts",
|
||||
"default": "./src/content.ts"
|
||||
},
|
||||
"./schemas": {
|
||||
"types": "./src/schemas.ts",
|
||||
"default": "./src/schemas.ts"
|
||||
},
|
||||
"./search": {
|
||||
"types": "./src/search.ts",
|
||||
"default": "./src/search.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
152
packages/shared-help-types/src/content.ts
Normal file
152
packages/shared-help-types/src/content.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* Help Content Type Definitions
|
||||
* Defines the structure for all help content types
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Base Types
|
||||
// ============================================================================
|
||||
|
||||
export type SupportedLanguage = 'en' | 'de' | 'fr' | 'it' | 'es';
|
||||
|
||||
export type FAQCategory = 'general' | 'account' | 'billing' | 'features' | 'technical' | 'privacy';
|
||||
|
||||
export type FeatureCategory = 'getting-started' | 'core' | 'advanced' | 'integration';
|
||||
|
||||
export type GuideDifficulty = 'beginner' | 'intermediate' | 'advanced';
|
||||
|
||||
export type ChangelogType = 'major' | 'minor' | 'patch' | 'beta';
|
||||
|
||||
export type ShortcutCategory = 'navigation' | 'editing' | 'general' | 'app-specific';
|
||||
|
||||
// ============================================================================
|
||||
// Content Item Types
|
||||
// ============================================================================
|
||||
|
||||
export interface BaseContentItem {
|
||||
id: string;
|
||||
language: SupportedLanguage;
|
||||
order?: number;
|
||||
appSpecific?: boolean;
|
||||
apps?: string[];
|
||||
lastUpdated?: Date;
|
||||
}
|
||||
|
||||
export interface FAQItem extends BaseContentItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
category: FAQCategory;
|
||||
featured?: boolean;
|
||||
tags?: string[];
|
||||
relatedFaqs?: string[];
|
||||
}
|
||||
|
||||
export interface FeatureItem extends BaseContentItem {
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
icon?: string;
|
||||
category: FeatureCategory;
|
||||
available?: boolean;
|
||||
comingSoon?: boolean;
|
||||
highlights?: string[];
|
||||
learnMoreUrl?: string;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
shortcut: string;
|
||||
action: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ShortcutsItem extends BaseContentItem {
|
||||
category: ShortcutCategory;
|
||||
title?: string;
|
||||
shortcuts: KeyboardShortcut[];
|
||||
}
|
||||
|
||||
export interface GuideStep {
|
||||
title: string;
|
||||
content: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export interface GettingStartedItem extends BaseContentItem {
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
difficulty: GuideDifficulty;
|
||||
estimatedTime?: string;
|
||||
prerequisites?: string[];
|
||||
steps?: GuideStep[];
|
||||
}
|
||||
|
||||
export interface ChangelogChange {
|
||||
title: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface ChangelogItem extends BaseContentItem {
|
||||
version: string;
|
||||
title: string;
|
||||
releaseDate: Date;
|
||||
type: ChangelogType;
|
||||
summary?: string;
|
||||
content: string;
|
||||
highlighted?: boolean;
|
||||
changes?: {
|
||||
features?: ChangelogChange[];
|
||||
improvements?: ChangelogChange[];
|
||||
bugfixes?: ChangelogChange[];
|
||||
};
|
||||
platforms?: string[];
|
||||
}
|
||||
|
||||
export interface ContactInfo extends BaseContentItem {
|
||||
title: string;
|
||||
content: string;
|
||||
supportEmail?: string;
|
||||
supportUrl?: string;
|
||||
discordUrl?: string;
|
||||
twitterUrl?: string;
|
||||
documentationUrl?: string;
|
||||
responseTime?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Aggregated Content Types
|
||||
// ============================================================================
|
||||
|
||||
export interface HelpContent {
|
||||
faq: FAQItem[];
|
||||
features: FeatureItem[];
|
||||
shortcuts: ShortcutsItem[];
|
||||
gettingStarted: GettingStartedItem[];
|
||||
changelog: ChangelogItem[];
|
||||
contact: ContactInfo | null;
|
||||
}
|
||||
|
||||
export interface AppHelpContent {
|
||||
appId: string;
|
||||
appName: string;
|
||||
content: HelpContent;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Types
|
||||
// ============================================================================
|
||||
|
||||
export interface HelpContentConfig {
|
||||
appId: string;
|
||||
locale: SupportedLanguage;
|
||||
fallbackLocale?: SupportedLanguage;
|
||||
includeAppSpecific?: boolean;
|
||||
}
|
||||
|
||||
export interface MergeContentOptions {
|
||||
appId: string;
|
||||
locale: SupportedLanguage;
|
||||
/** If true, app-specific content replaces central content with same ID */
|
||||
overrideById?: boolean;
|
||||
}
|
||||
13
packages/shared-help-types/src/index.ts
Normal file
13
packages/shared-help-types/src/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* @manacore/shared-help-types
|
||||
* Shared TypeScript types and Zod schemas for Help content
|
||||
*/
|
||||
|
||||
// Content types
|
||||
export * from './content.js';
|
||||
|
||||
// Zod schemas for validation
|
||||
export * from './schemas.js';
|
||||
|
||||
// Search types
|
||||
export * from './search.js';
|
||||
130
packages/shared-help-types/src/schemas.ts
Normal file
130
packages/shared-help-types/src/schemas.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Zod Schemas for Help Content Validation
|
||||
* Used to validate Markdown frontmatter
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ============================================================================
|
||||
// Base Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const supportedLanguageSchema = z.enum(['en', 'de', 'fr', 'it', 'es']);
|
||||
|
||||
export const faqCategorySchema = z.enum([
|
||||
'general',
|
||||
'account',
|
||||
'billing',
|
||||
'features',
|
||||
'technical',
|
||||
'privacy',
|
||||
]);
|
||||
|
||||
export const featureCategorySchema = z.enum(['getting-started', 'core', 'advanced', 'integration']);
|
||||
|
||||
export const guideDifficultySchema = z.enum(['beginner', 'intermediate', 'advanced']);
|
||||
|
||||
export const changelogTypeSchema = z.enum(['major', 'minor', 'patch', 'beta']);
|
||||
|
||||
export const shortcutCategorySchema = z.enum(['navigation', 'editing', 'general', 'app-specific']);
|
||||
|
||||
// ============================================================================
|
||||
// Content Item Schemas (for Frontmatter)
|
||||
// ============================================================================
|
||||
|
||||
const baseContentSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
language: supportedLanguageSchema,
|
||||
order: z.number().optional().default(0),
|
||||
appSpecific: z.boolean().optional().default(false),
|
||||
apps: z.array(z.string()).optional().default([]),
|
||||
lastUpdated: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export const faqFrontmatterSchema = baseContentSchema.extend({
|
||||
question: z.string().min(1),
|
||||
category: faqCategorySchema,
|
||||
featured: z.boolean().optional().default(false),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
relatedFaqs: z.array(z.string()).optional().default([]),
|
||||
});
|
||||
|
||||
export const featureFrontmatterSchema = baseContentSchema.extend({
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
icon: z.string().optional(),
|
||||
category: featureCategorySchema,
|
||||
available: z.boolean().optional().default(true),
|
||||
comingSoon: z.boolean().optional().default(false),
|
||||
highlights: z.array(z.string()).optional().default([]),
|
||||
learnMoreUrl: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export const shortcutSchema = z.object({
|
||||
shortcut: z.string().min(1),
|
||||
action: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const shortcutsFrontmatterSchema = baseContentSchema.extend({
|
||||
category: shortcutCategorySchema,
|
||||
title: z.string().optional(),
|
||||
});
|
||||
|
||||
export const guideStepSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
content: z.string().min(1),
|
||||
duration: z.string().optional(),
|
||||
});
|
||||
|
||||
export const gettingStartedFrontmatterSchema = baseContentSchema.extend({
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
difficulty: guideDifficultySchema,
|
||||
estimatedTime: z.string().optional(),
|
||||
prerequisites: z.array(z.string()).optional().default([]),
|
||||
});
|
||||
|
||||
export const changelogChangeSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
});
|
||||
|
||||
export const changelogFrontmatterSchema = baseContentSchema.extend({
|
||||
version: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
releaseDate: z.coerce.date(),
|
||||
type: changelogTypeSchema,
|
||||
summary: z.string().optional(),
|
||||
highlighted: z.boolean().optional().default(false),
|
||||
changes: z
|
||||
.object({
|
||||
features: z.array(changelogChangeSchema).optional(),
|
||||
improvements: z.array(changelogChangeSchema).optional(),
|
||||
bugfixes: z.array(changelogChangeSchema).optional(),
|
||||
})
|
||||
.optional(),
|
||||
platforms: z.array(z.string()).optional().default(['all']),
|
||||
});
|
||||
|
||||
export const contactFrontmatterSchema = baseContentSchema.extend({
|
||||
title: z.string().min(1),
|
||||
supportEmail: z.string().email().optional(),
|
||||
supportUrl: z.string().url().optional(),
|
||||
discordUrl: z.string().url().optional(),
|
||||
twitterUrl: z.string().url().optional(),
|
||||
documentationUrl: z.string().url().optional(),
|
||||
responseTime: z.string().optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Type Exports from Schemas
|
||||
// ============================================================================
|
||||
|
||||
export type FAQFrontmatter = z.infer<typeof faqFrontmatterSchema>;
|
||||
export type FeatureFrontmatter = z.infer<typeof featureFrontmatterSchema>;
|
||||
export type ShortcutsFrontmatter = z.infer<typeof shortcutsFrontmatterSchema>;
|
||||
export type GettingStartedFrontmatter = z.infer<typeof gettingStartedFrontmatterSchema>;
|
||||
export type ChangelogFrontmatter = z.infer<typeof changelogFrontmatterSchema>;
|
||||
export type ContactFrontmatter = z.infer<typeof contactFrontmatterSchema>;
|
||||
71
packages/shared-help-types/src/search.ts
Normal file
71
packages/shared-help-types/src/search.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Search-related Type Definitions
|
||||
*/
|
||||
|
||||
import type { FAQItem, FeatureItem, GettingStartedItem, ChangelogItem } from './content.js';
|
||||
|
||||
// ============================================================================
|
||||
// Searchable Item Types
|
||||
// ============================================================================
|
||||
|
||||
export type SearchableContentType = 'faq' | 'feature' | 'guide' | 'changelog';
|
||||
|
||||
export interface SearchableItem {
|
||||
id: string;
|
||||
type: SearchableContentType;
|
||||
title: string;
|
||||
content: string;
|
||||
tags?: string[];
|
||||
question?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search Result Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
type: SearchableContentType;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
score: number;
|
||||
highlight?: string;
|
||||
/** Original item reference */
|
||||
item: FAQItem | FeatureItem | GettingStartedItem | ChangelogItem;
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
/** Maximum number of results to return */
|
||||
limit?: number;
|
||||
/** Minimum score threshold (0-1, lower is more strict) */
|
||||
threshold?: number;
|
||||
/** Filter by content type */
|
||||
types?: SearchableContentType[];
|
||||
/** Filter by app ID (for app-specific content) */
|
||||
appId?: string;
|
||||
}
|
||||
|
||||
export interface SearchIndexConfig {
|
||||
/** Weight for title/question field */
|
||||
titleWeight?: number;
|
||||
/** Weight for content field */
|
||||
contentWeight?: number;
|
||||
/** Weight for tags field */
|
||||
tagsWeight?: number;
|
||||
/** Fuzzy match threshold (0-1, lower is more strict) */
|
||||
threshold?: number;
|
||||
/** Minimum characters to start searching */
|
||||
minMatchCharLength?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Search State Types (for UI)
|
||||
// ============================================================================
|
||||
|
||||
export interface SearchState {
|
||||
query: string;
|
||||
results: SearchResult[];
|
||||
isSearching: boolean;
|
||||
hasSearched: boolean;
|
||||
}
|
||||
16
packages/shared-help-types/tsconfig.json
Normal file
16
packages/shared-help-types/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
65
packages/shared-help-ui/package.json
Normal file
65
packages/shared-help-ui/package.json
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"name": "@manacore/shared-help-ui",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"svelte": "./src/index.ts",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"svelte": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./HelpPage.svelte": {
|
||||
"svelte": "./src/pages/HelpPage.svelte",
|
||||
"default": "./src/pages/HelpPage.svelte"
|
||||
},
|
||||
"./FAQSection.svelte": {
|
||||
"svelte": "./src/components/FAQSection.svelte",
|
||||
"default": "./src/components/FAQSection.svelte"
|
||||
},
|
||||
"./FeaturesOverview.svelte": {
|
||||
"svelte": "./src/components/FeaturesOverview.svelte",
|
||||
"default": "./src/components/FeaturesOverview.svelte"
|
||||
},
|
||||
"./KeyboardShortcuts.svelte": {
|
||||
"svelte": "./src/components/KeyboardShortcuts.svelte",
|
||||
"default": "./src/components/KeyboardShortcuts.svelte"
|
||||
},
|
||||
"./GettingStartedGuide.svelte": {
|
||||
"svelte": "./src/components/GettingStartedGuide.svelte",
|
||||
"default": "./src/components/GettingStartedGuide.svelte"
|
||||
},
|
||||
"./ChangelogSection.svelte": {
|
||||
"svelte": "./src/components/ChangelogSection.svelte",
|
||||
"default": "./src/components/ChangelogSection.svelte"
|
||||
},
|
||||
"./ContactSection.svelte": {
|
||||
"svelte": "./src/components/ContactSection.svelte",
|
||||
"default": "./src/components/ContactSection.svelte"
|
||||
},
|
||||
"./HelpSearch.svelte": {
|
||||
"svelte": "./src/components/HelpSearch.svelte",
|
||||
"default": "./src/components/HelpSearch.svelte"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-help-types": "workspace:*",
|
||||
"@manacore/shared-help-content": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
}
|
||||
}
|
||||
152
packages/shared-help-ui/src/components/ChangelogEntry.svelte
Normal file
152
packages/shared-help-ui/src/components/ChangelogEntry.svelte
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<script lang="ts">
|
||||
import type { ChangelogItem } from '@manacore/shared-help-types';
|
||||
|
||||
interface Props {
|
||||
item: ChangelogItem;
|
||||
typeLabels: Record<string, string>;
|
||||
}
|
||||
|
||||
let { item, typeLabels }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
|
||||
function getTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'major':
|
||||
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400';
|
||||
case 'minor':
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
case 'patch':
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'beta':
|
||||
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return new Date(date).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border-b border-gray-200 py-4 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start justify-between text-left"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
>
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
v{item.version}
|
||||
</span>
|
||||
<span class={`rounded-full px-2 py-0.5 text-xs font-medium ${getTypeColor(item.type)}`}>
|
||||
{typeLabels[item.type] ?? item.type}
|
||||
</span>
|
||||
{#if item.highlighted}
|
||||
<span
|
||||
class="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
>
|
||||
Featured
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<h4 class="mt-1 font-medium text-gray-800 dark:text-gray-200">
|
||||
{item.title}
|
||||
</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatDate(item.releaseDate)}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="mt-1 text-gray-500 transition-transform duration-200 dark:text-gray-400"
|
||||
class:rotate-180={expanded}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="mt-4 space-y-4">
|
||||
{#if item.summary}
|
||||
<p class="text-gray-600 dark:text-gray-400">{item.summary}</p>
|
||||
{/if}
|
||||
|
||||
{#if item.changes}
|
||||
{#if item.changes.features && item.changes.features.length > 0}
|
||||
<div>
|
||||
<h5 class="mb-2 text-sm font-semibold text-green-600 dark:text-green-400">
|
||||
New Features
|
||||
</h5>
|
||||
<ul class="space-y-1">
|
||||
{#each item.changes.features as change}
|
||||
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="text-green-500">+</span>
|
||||
<span>
|
||||
<strong>{change.title}</strong>
|
||||
{#if change.description}
|
||||
- {change.description}
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.changes.improvements && item.changes.improvements.length > 0}
|
||||
<div>
|
||||
<h5 class="mb-2 text-sm font-semibold text-blue-600 dark:text-blue-400">
|
||||
Improvements
|
||||
</h5>
|
||||
<ul class="space-y-1">
|
||||
{#each item.changes.improvements as change}
|
||||
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="text-blue-500">↑</span>
|
||||
<span>
|
||||
<strong>{change.title}</strong>
|
||||
{#if change.description}
|
||||
- {change.description}
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.changes.bugfixes && item.changes.bugfixes.length > 0}
|
||||
<div>
|
||||
<h5 class="mb-2 text-sm font-semibold text-red-600 dark:text-red-400">Bug Fixes</h5>
|
||||
<ul class="space-y-1">
|
||||
{#each item.changes.bugfixes as change}
|
||||
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span class="text-red-500">×</span>
|
||||
<span>
|
||||
<strong>{change.title}</strong>
|
||||
{#if change.description}
|
||||
- {change.description}
|
||||
{/if}
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if item.content}
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
{@html item.content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<script lang="ts">
|
||||
import type { ChangelogSectionProps } from '../types.js';
|
||||
import ChangelogEntry from './ChangelogEntry.svelte';
|
||||
|
||||
let { items, translations, maxItems = 10 }: ChangelogSectionProps = $props();
|
||||
|
||||
let showAll = $state(false);
|
||||
|
||||
const sortedItems = $derived(() => {
|
||||
return [...items].sort(
|
||||
(a, b) => new Date(b.releaseDate).getTime() - new Date(a.releaseDate).getTime()
|
||||
);
|
||||
});
|
||||
|
||||
const displayedItems = $derived(() => {
|
||||
if (showAll) return sortedItems();
|
||||
return sortedItems().slice(0, maxItems);
|
||||
});
|
||||
|
||||
const hasMore = $derived(items.length > maxItems && !showAll);
|
||||
</script>
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translations.changelog.noItems}
|
||||
</p>
|
||||
{:else}
|
||||
<div>
|
||||
{#each displayedItems() as item (item.id)}
|
||||
<ChangelogEntry {item} typeLabels={translations.changelog.types} />
|
||||
{/each}
|
||||
|
||||
{#if hasMore}
|
||||
<div class="pt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
|
||||
onclick={() => (showAll = true)}
|
||||
>
|
||||
Show all releases ({items.length - maxItems} more)
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
123
packages/shared-help-ui/src/components/ContactSection.svelte
Normal file
123
packages/shared-help-ui/src/components/ContactSection.svelte
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<script lang="ts">
|
||||
import type { ContactSectionProps } from '../types.js';
|
||||
|
||||
let { contact, translations }: ContactSectionProps = $props();
|
||||
</script>
|
||||
|
||||
{#if !contact}
|
||||
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translations.contact.noInfo}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
{@html contact.content}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#if contact.supportEmail}
|
||||
<a
|
||||
href={`mailto:${contact.supportEmail}`}
|
||||
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div
|
||||
class="bg-primary-100 text-primary-600 dark:bg-primary-900 dark:text-primary-400 flex h-10 w-10 items-center justify-center rounded-full"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{translations.contact.email}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{contact.supportEmail}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if contact.responseTime}
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 dark:border-gray-700"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{translations.contact.responseTime}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{contact.responseTime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if contact.discordUrl}
|
||||
<a
|
||||
href={contact.discordUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-900 dark:text-indigo-400"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">Discord</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Join our community</p>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if contact.documentationUrl}
|
||||
<a
|
||||
href={contact.documentationUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-gray-100">Documentation</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Read the docs</p>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
46
packages/shared-help-ui/src/components/FAQItem.svelte
Normal file
46
packages/shared-help-ui/src/components/FAQItem.svelte
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import type { FAQItem } from '@manacore/shared-help-types';
|
||||
|
||||
interface Props {
|
||||
item: FAQItem;
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
let { item, expanded = false, onToggle }: Props = $props();
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onToggle?.();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between py-4 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
aria-expanded={expanded}
|
||||
onclick={onToggle}
|
||||
onkeydown={handleKeyDown}
|
||||
>
|
||||
<span class="pr-4 font-medium text-gray-900 dark:text-gray-100">
|
||||
{item.question}
|
||||
</span>
|
||||
<span
|
||||
class="flex-shrink-0 text-gray-500 transition-transform duration-200 dark:text-gray-400"
|
||||
class:rotate-180={expanded}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none pb-4 text-gray-600 dark:text-gray-300">
|
||||
{@html item.answer}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
117
packages/shared-help-ui/src/components/FAQSection.svelte
Normal file
117
packages/shared-help-ui/src/components/FAQSection.svelte
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<script lang="ts">
|
||||
import type { FAQItem, FAQCategory } from '@manacore/shared-help-types';
|
||||
import type { FAQSectionProps } from '../types.js';
|
||||
import FAQItemComponent from './FAQItem.svelte';
|
||||
|
||||
let {
|
||||
items,
|
||||
translations,
|
||||
showCategories = true,
|
||||
maxItems,
|
||||
expandFirst = false,
|
||||
}: FAQSectionProps = $props();
|
||||
|
||||
let expandedId = $state<string | null>(expandFirst && items.length > 0 ? items[0].id : null);
|
||||
let selectedCategory = $state<FAQCategory | 'all'>('all');
|
||||
let showAll = $state(false);
|
||||
|
||||
const categories: FAQCategory[] = [
|
||||
'general',
|
||||
'account',
|
||||
'billing',
|
||||
'features',
|
||||
'technical',
|
||||
'privacy',
|
||||
];
|
||||
|
||||
const filteredItems = $derived(() => {
|
||||
let result = items;
|
||||
if (selectedCategory !== 'all') {
|
||||
result = result.filter((item) => item.category === selectedCategory);
|
||||
}
|
||||
if (maxItems && !showAll) {
|
||||
result = result.slice(0, maxItems);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const hasMore = $derived(maxItems ? items.length > maxItems && !showAll : false);
|
||||
|
||||
function toggleItem(id: string) {
|
||||
expandedId = expandedId === id ? null : id;
|
||||
}
|
||||
|
||||
function getCategoryLabel(category: FAQCategory): string {
|
||||
return translations.faq.categories[category] ?? category;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if showCategories && items.length > 0}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
class:bg-primary-100={selectedCategory === 'all'}
|
||||
class:text-primary-700={selectedCategory === 'all'}
|
||||
class:dark:bg-primary-900={selectedCategory === 'all'}
|
||||
class:dark:text-primary-300={selectedCategory === 'all'}
|
||||
class:bg-gray-100={selectedCategory !== 'all'}
|
||||
class:text-gray-600={selectedCategory !== 'all'}
|
||||
class:dark:bg-gray-800={selectedCategory !== 'all'}
|
||||
class:dark:text-gray-400={selectedCategory !== 'all'}
|
||||
onclick={() => (selectedCategory = 'all')}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{#each categories as category}
|
||||
{@const hasItems = items.some((item) => item.category === category)}
|
||||
{#if hasItems}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
class:bg-primary-100={selectedCategory === category}
|
||||
class:text-primary-700={selectedCategory === category}
|
||||
class:dark:bg-primary-900={selectedCategory === category}
|
||||
class:dark:text-primary-300={selectedCategory === category}
|
||||
class:bg-gray-100={selectedCategory !== category}
|
||||
class:text-gray-600={selectedCategory !== category}
|
||||
class:dark:bg-gray-800={selectedCategory !== category}
|
||||
class:dark:text-gray-400={selectedCategory !== category}
|
||||
onclick={() => (selectedCategory = category)}
|
||||
>
|
||||
{getCategoryLabel(category)}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if filteredItems().length === 0}
|
||||
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translations.faq.noItems}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{#each filteredItems() as item (item.id)}
|
||||
<FAQItemComponent
|
||||
{item}
|
||||
expanded={expandedId === item.id}
|
||||
onToggle={() => toggleItem(item.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasMore}
|
||||
<div class="pt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
|
||||
onclick={() => (showAll = true)}
|
||||
>
|
||||
{translations.common.showMore}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
68
packages/shared-help-ui/src/components/FeatureCard.svelte
Normal file
68
packages/shared-help-ui/src/components/FeatureCard.svelte
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<script lang="ts">
|
||||
import type { FeatureItem } from '@manacore/shared-help-types';
|
||||
|
||||
interface Props {
|
||||
item: FeatureItem;
|
||||
learnMoreLabel?: string;
|
||||
comingSoonLabel?: string;
|
||||
}
|
||||
|
||||
let { item, learnMoreLabel = 'Learn more', comingSoonLabel = 'Coming soon' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative rounded-lg border border-gray-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
{#if item.comingSoon}
|
||||
<span
|
||||
class="absolute right-3 top-3 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
{comingSoonLabel}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
{#if item.icon}
|
||||
<span class="text-2xl">{item.icon}</span>
|
||||
{/if}
|
||||
<h3 class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{item.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="mb-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{item.description}
|
||||
</p>
|
||||
|
||||
{#if item.highlights && item.highlights.length > 0}
|
||||
<ul class="mb-3 space-y-1">
|
||||
{#each item.highlights as highlight}
|
||||
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
class="text-primary-500 mt-0.5 h-4 w-4 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{highlight}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if item.learnMoreUrl}
|
||||
<a
|
||||
href={item.learnMoreUrl}
|
||||
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
|
||||
>
|
||||
{learnMoreLabel} →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts">
|
||||
import type { FeaturesOverviewProps } from '../types.js';
|
||||
import FeatureCard from './FeatureCard.svelte';
|
||||
|
||||
let { items, translations }: FeaturesOverviewProps = $props();
|
||||
|
||||
const groupedItems = $derived(() => {
|
||||
const groups: Record<string, typeof items> = {
|
||||
'getting-started': [],
|
||||
core: [],
|
||||
advanced: [],
|
||||
integration: [],
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const category = item.category || 'core';
|
||||
if (groups[category]) {
|
||||
groups[category].push(item);
|
||||
} else {
|
||||
groups.core.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
const hasItems = $derived(items.length > 0);
|
||||
</script>
|
||||
|
||||
{#if !hasItems}
|
||||
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translations.features.noItems}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-8">
|
||||
{#each Object.entries(groupedItems()) as [_category, categoryItems]}
|
||||
{#if categoryItems.length > 0}
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each categoryItems as item (item.id)}
|
||||
<FeatureCard
|
||||
{item}
|
||||
learnMoreLabel={translations.features.learnMore}
|
||||
comingSoonLabel={translations.features.comingSoon}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
<script lang="ts">
|
||||
import type { GettingStartedGuideProps } from '../types.js';
|
||||
|
||||
let { items, translations }: GettingStartedGuideProps = $props();
|
||||
|
||||
let selectedGuideId = $state<string | null>(items.length > 0 ? items[0].id : null);
|
||||
|
||||
const selectedGuide = $derived(() => {
|
||||
return items.find((item) => item.id === selectedGuideId) ?? null;
|
||||
});
|
||||
|
||||
function getDifficultyLabel(difficulty: string): string {
|
||||
return (
|
||||
translations.gettingStarted.difficulty[
|
||||
difficulty as keyof typeof translations.gettingStarted.difficulty
|
||||
] ?? difficulty
|
||||
);
|
||||
}
|
||||
|
||||
function getDifficultyColor(difficulty: string): string {
|
||||
switch (difficulty) {
|
||||
case 'beginner':
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'intermediate':
|
||||
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
|
||||
case 'advanced':
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translations.gettingStarted.noItems}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-6 lg:flex-row">
|
||||
<!-- Guide List -->
|
||||
<div class="w-full space-y-2 lg:w-64">
|
||||
{#each items as item (item.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg border p-3 text-left transition-colors {selectedGuideId ===
|
||||
item.id
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800'}"
|
||||
onclick={() => (selectedGuideId = item.id)}
|
||||
>
|
||||
<h4 class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{item.title}
|
||||
</h4>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class={`rounded-full px-2 py-0.5 ${getDifficultyColor(item.difficulty)}`}>
|
||||
{getDifficultyLabel(item.difficulty)}
|
||||
</span>
|
||||
{#if item.estimatedTime}
|
||||
<span class="text-gray-500 dark:text-gray-400">
|
||||
{item.estimatedTime}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Guide Content -->
|
||||
<div class="flex-1">
|
||||
{#if selectedGuide()}
|
||||
{@const guide = selectedGuide()}
|
||||
<div>
|
||||
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{guide.title}
|
||||
</h3>
|
||||
<p class="mb-4 text-gray-600 dark:text-gray-400">
|
||||
{guide.description}
|
||||
</p>
|
||||
|
||||
{#if guide.steps && guide.steps.length > 0}
|
||||
<div class="space-y-6">
|
||||
{#each guide.steps as step, index}
|
||||
<div class="flex gap-4">
|
||||
<div
|
||||
class="bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-sm font-semibold"
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-2 font-medium text-gray-900 dark:text-gray-100">
|
||||
{step.title}
|
||||
</h4>
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert max-w-none text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
{step.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||
{@html guide.content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
198
packages/shared-help-ui/src/components/HelpSearch.svelte
Normal file
198
packages/shared-help-ui/src/components/HelpSearch.svelte
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<script lang="ts">
|
||||
import type { HelpSearchProps } from '../types.js';
|
||||
import type { SearchResult } from '@manacore/shared-help-types';
|
||||
import { createSearcher } from '@manacore/shared-help-content';
|
||||
|
||||
let { content, translations, placeholder, onResultSelect }: HelpSearchProps = $props();
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<SearchResult[]>([]);
|
||||
let isSearching = $state(false);
|
||||
let showResults = $state(false);
|
||||
let selectedIndex = $state(-1);
|
||||
|
||||
const searcher = $derived(createSearcher(content));
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
query = target.value;
|
||||
selectedIndex = -1;
|
||||
|
||||
clearTimeout(debounceTimer);
|
||||
|
||||
if (query.trim().length < 2) {
|
||||
results = [];
|
||||
showResults = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isSearching = true;
|
||||
debounceTimer = setTimeout(() => {
|
||||
results = searcher(query, { limit: 8 });
|
||||
isSearching = false;
|
||||
showResults = true;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!showResults || results.length === 0) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
selectedIndex = Math.max(selectedIndex - 1, -1);
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (selectedIndex >= 0 && results[selectedIndex]) {
|
||||
selectResult(results[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
showResults = false;
|
||||
selectedIndex = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function selectResult(result: SearchResult) {
|
||||
onResultSelect(result);
|
||||
query = '';
|
||||
results = [];
|
||||
showResults = false;
|
||||
selectedIndex = -1;
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Delay to allow click on result
|
||||
setTimeout(() => {
|
||||
showResults = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function getTypeIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'faq':
|
||||
return '?';
|
||||
case 'feature':
|
||||
return '★';
|
||||
case 'guide':
|
||||
return '📖';
|
||||
case 'changelog':
|
||||
return '📋';
|
||||
default:
|
||||
return '•';
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'faq':
|
||||
return 'FAQ';
|
||||
case 'feature':
|
||||
return 'Feature';
|
||||
case 'guide':
|
||||
return 'Guide';
|
||||
case 'changelog':
|
||||
return 'Changelog';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeyDown}
|
||||
onfocus={() => query.length >= 2 && (showResults = true)}
|
||||
onblur={handleBlur}
|
||||
placeholder={placeholder ?? translations.search.noResults}
|
||||
class="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-4 text-sm text-gray-900 placeholder-gray-500 transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
{#if isSearching}
|
||||
<svg class="h-5 w-5 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showResults}
|
||||
<div
|
||||
class="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
{#if results.length === 0}
|
||||
<div class="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{translations.search.noResults.replace('{query}', query)}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="max-h-96 overflow-auto py-2">
|
||||
{#each results as result, index (result.id)}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-start gap-3 px-4 py-2 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 {selectedIndex ===
|
||||
index
|
||||
? 'bg-primary-50 dark:bg-primary-900/20'
|
||||
: ''}"
|
||||
onclick={() => selectResult(result)}
|
||||
>
|
||||
<span
|
||||
class="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-gray-100 text-xs dark:bg-gray-700"
|
||||
>
|
||||
{getTypeIcon(result.type)}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-medium text-gray-900 dark:text-gray-100">
|
||||
{@html result.highlight ?? result.title}
|
||||
</span>
|
||||
<span
|
||||
class="flex-shrink-0 rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{getTypeLabel(result.type)}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-0.5 truncate text-sm text-gray-500 dark:text-gray-400">
|
||||
{result.excerpt}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<div
|
||||
class="border-t border-gray-200 px-4 py-2 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400"
|
||||
>
|
||||
{translations.search.resultsCount.replace('{count}', String(results.length))}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<script lang="ts">
|
||||
import type { KeyboardShortcutsProps } from '../types.js';
|
||||
|
||||
let { items, translations }: KeyboardShortcutsProps = $props();
|
||||
|
||||
const allShortcuts = $derived(() => {
|
||||
return items.flatMap((item) =>
|
||||
item.shortcuts.map((shortcut) => ({
|
||||
...shortcut,
|
||||
category: item.category,
|
||||
title: item.title,
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
const hasItems = $derived(allShortcuts().length > 0);
|
||||
</script>
|
||||
|
||||
{#if !hasItems}
|
||||
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{translations.shortcuts.noItems}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100">Shortcut</th>
|
||||
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100">Action</th>
|
||||
<th class="pb-3 font-semibold text-gray-900 dark:text-gray-100">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{#each allShortcuts() as shortcut}
|
||||
<tr>
|
||||
<td class="py-3 pr-4">
|
||||
<kbd
|
||||
class="rounded bg-gray-100 px-2 py-1 font-mono text-xs text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
>
|
||||
{shortcut.shortcut}
|
||||
</kbd>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-gray-900 dark:text-gray-100">
|
||||
{shortcut.action}
|
||||
</td>
|
||||
<td class="py-3 text-gray-600 dark:text-gray-400">
|
||||
{shortcut.description || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
33
packages/shared-help-ui/src/index.ts
Normal file
33
packages/shared-help-ui/src/index.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* @manacore/shared-help-ui
|
||||
* Svelte 5 components for the Help page system
|
||||
*/
|
||||
|
||||
// Main page component
|
||||
export { default as HelpPage } from './pages/HelpPage.svelte';
|
||||
|
||||
// Section components
|
||||
export { default as FAQSection } from './components/FAQSection.svelte';
|
||||
export { default as FAQItem } from './components/FAQItem.svelte';
|
||||
export { default as FeaturesOverview } from './components/FeaturesOverview.svelte';
|
||||
export { default as FeatureCard } from './components/FeatureCard.svelte';
|
||||
export { default as KeyboardShortcuts } from './components/KeyboardShortcuts.svelte';
|
||||
export { default as GettingStartedGuide } from './components/GettingStartedGuide.svelte';
|
||||
export { default as ChangelogSection } from './components/ChangelogSection.svelte';
|
||||
export { default as ChangelogEntry } from './components/ChangelogEntry.svelte';
|
||||
export { default as ContactSection } from './components/ContactSection.svelte';
|
||||
export { default as HelpSearch } from './components/HelpSearch.svelte';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
HelpPageProps,
|
||||
HelpPageTranslations,
|
||||
HelpSection,
|
||||
FAQSectionProps,
|
||||
FeaturesOverviewProps,
|
||||
KeyboardShortcutsProps,
|
||||
GettingStartedGuideProps,
|
||||
ChangelogSectionProps,
|
||||
ContactSectionProps,
|
||||
HelpSearchProps,
|
||||
} from './types.js';
|
||||
169
packages/shared-help-ui/src/pages/HelpPage.svelte
Normal file
169
packages/shared-help-ui/src/pages/HelpPage.svelte
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
<script lang="ts">
|
||||
import type { HelpPageProps, HelpSection } from '../types.js';
|
||||
import type { SearchResult } from '@manacore/shared-help-types';
|
||||
import HelpSearch from '../components/HelpSearch.svelte';
|
||||
import FAQSection from '../components/FAQSection.svelte';
|
||||
import FeaturesOverview from '../components/FeaturesOverview.svelte';
|
||||
import KeyboardShortcuts from '../components/KeyboardShortcuts.svelte';
|
||||
import GettingStartedGuide from '../components/GettingStartedGuide.svelte';
|
||||
import ChangelogSection from '../components/ChangelogSection.svelte';
|
||||
import ContactSection from '../components/ContactSection.svelte';
|
||||
|
||||
let {
|
||||
content,
|
||||
appName,
|
||||
appId: _appId,
|
||||
translations,
|
||||
searchEnabled = true,
|
||||
showFAQ = true,
|
||||
showFeatures = true,
|
||||
showShortcuts = true,
|
||||
showGettingStarted = true,
|
||||
showChangelog = true,
|
||||
showContact = true,
|
||||
defaultSection = 'faq',
|
||||
showBackButton = false,
|
||||
onBack,
|
||||
onSectionChange,
|
||||
onSearch,
|
||||
}: HelpPageProps = $props();
|
||||
|
||||
let activeSection = $state<HelpSection>(defaultSection);
|
||||
|
||||
const sections: { id: HelpSection; label: string; show: boolean }[] = $derived([
|
||||
{ id: 'faq', label: translations.sections.faq, show: showFAQ && content.faq.length > 0 },
|
||||
{
|
||||
id: 'features',
|
||||
label: translations.sections.features,
|
||||
show: showFeatures && content.features.length > 0,
|
||||
},
|
||||
{
|
||||
id: 'shortcuts',
|
||||
label: translations.sections.shortcuts,
|
||||
show: showShortcuts && content.shortcuts.length > 0,
|
||||
},
|
||||
{
|
||||
id: 'getting-started',
|
||||
label: translations.sections.gettingStarted,
|
||||
show: showGettingStarted && content.gettingStarted.length > 0,
|
||||
},
|
||||
{
|
||||
id: 'changelog',
|
||||
label: translations.sections.changelog,
|
||||
show: showChangelog && content.changelog.length > 0,
|
||||
},
|
||||
{ id: 'contact', label: translations.sections.contact, show: showContact && !!content.contact },
|
||||
]);
|
||||
|
||||
const visibleSections = $derived(sections.filter((s) => s.show));
|
||||
|
||||
function setActiveSection(section: HelpSection) {
|
||||
activeSection = section;
|
||||
onSectionChange?.(section);
|
||||
}
|
||||
|
||||
function handleSearchResultSelect(result: SearchResult) {
|
||||
// Navigate to the appropriate section based on result type
|
||||
switch (result.type) {
|
||||
case 'faq':
|
||||
setActiveSection('faq');
|
||||
break;
|
||||
case 'feature':
|
||||
setActiveSection('features');
|
||||
break;
|
||||
case 'guide':
|
||||
setActiveSection('getting-started');
|
||||
break;
|
||||
case 'changelog':
|
||||
setActiveSection('changelog');
|
||||
break;
|
||||
}
|
||||
onSearch?.(result.title, [result]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
{#if showBackButton}
|
||||
<button
|
||||
type="button"
|
||||
class="mb-4 flex items-center gap-1 text-sm text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||
onclick={onBack}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
{translations.common.back}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{translations.title}
|
||||
</h1>
|
||||
{#if translations.subtitle}
|
||||
<p class="mt-1 text-gray-600 dark:text-gray-400">
|
||||
{translations.subtitle} - {appName}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
{#if searchEnabled}
|
||||
<div class="mb-8">
|
||||
<HelpSearch
|
||||
{content}
|
||||
{translations}
|
||||
placeholder={translations.searchPlaceholder}
|
||||
onResultSelect={handleSearchResultSelect}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation Tabs -->
|
||||
{#if visibleSections.length > 1}
|
||||
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="-mb-px flex space-x-4 overflow-x-auto" aria-label="Help sections">
|
||||
{#each visibleSections as section (section.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium transition-colors"
|
||||
class:border-primary-500={activeSection === section.id}
|
||||
class:text-primary-600={activeSection === section.id}
|
||||
class:dark:text-primary-400={activeSection === section.id}
|
||||
class:border-transparent={activeSection !== section.id}
|
||||
class:text-gray-500={activeSection !== section.id}
|
||||
class:hover:text-gray-700={activeSection !== section.id}
|
||||
class:dark:text-gray-400={activeSection !== section.id}
|
||||
class:dark:hover:text-gray-300={activeSection !== section.id}
|
||||
onclick={() => setActiveSection(section.id)}
|
||||
>
|
||||
{section.label}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-h-[400px]">
|
||||
{#if activeSection === 'faq' && showFAQ}
|
||||
<FAQSection items={content.faq} {translations} expandFirst />
|
||||
{:else if activeSection === 'features' && showFeatures}
|
||||
<FeaturesOverview items={content.features} {translations} />
|
||||
{:else if activeSection === 'shortcuts' && showShortcuts}
|
||||
<KeyboardShortcuts items={content.shortcuts} {translations} />
|
||||
{:else if activeSection === 'getting-started' && showGettingStarted}
|
||||
<GettingStartedGuide items={content.gettingStarted} {translations} />
|
||||
{:else if activeSection === 'changelog' && showChangelog}
|
||||
<ChangelogSection items={content.changelog} {translations} />
|
||||
{:else if activeSection === 'contact' && showContact}
|
||||
<ContactSection contact={content.contact} {translations} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
147
packages/shared-help-ui/src/types.ts
Normal file
147
packages/shared-help-ui/src/types.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* Component Props and Translation Types
|
||||
*/
|
||||
|
||||
import type { HelpContent, SearchResult } from '@manacore/shared-help-types';
|
||||
|
||||
// ============================================================================
|
||||
// Translation Types
|
||||
// ============================================================================
|
||||
|
||||
export interface HelpPageTranslations {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
searchPlaceholder: string;
|
||||
sections: {
|
||||
faq: string;
|
||||
features: string;
|
||||
shortcuts: string;
|
||||
gettingStarted: string;
|
||||
changelog: string;
|
||||
contact: string;
|
||||
};
|
||||
search: {
|
||||
noResults: string;
|
||||
resultsCount: string;
|
||||
searching: string;
|
||||
};
|
||||
faq: {
|
||||
noItems: string;
|
||||
categories: {
|
||||
general: string;
|
||||
account: string;
|
||||
billing: string;
|
||||
features: string;
|
||||
technical: string;
|
||||
privacy: string;
|
||||
};
|
||||
};
|
||||
features: {
|
||||
noItems: string;
|
||||
comingSoon: string;
|
||||
learnMore: string;
|
||||
};
|
||||
shortcuts: {
|
||||
noItems: string;
|
||||
};
|
||||
gettingStarted: {
|
||||
noItems: string;
|
||||
estimatedTime: string;
|
||||
difficulty: {
|
||||
beginner: string;
|
||||
intermediate: string;
|
||||
advanced: string;
|
||||
};
|
||||
};
|
||||
changelog: {
|
||||
noItems: string;
|
||||
types: {
|
||||
major: string;
|
||||
minor: string;
|
||||
patch: string;
|
||||
beta: string;
|
||||
};
|
||||
};
|
||||
contact: {
|
||||
noInfo: string;
|
||||
email: string;
|
||||
responseTime: string;
|
||||
};
|
||||
common: {
|
||||
back: string;
|
||||
showMore: string;
|
||||
showLess: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component Props
|
||||
// ============================================================================
|
||||
|
||||
export type HelpSection =
|
||||
| 'faq'
|
||||
| 'features'
|
||||
| 'shortcuts'
|
||||
| 'getting-started'
|
||||
| 'changelog'
|
||||
| 'contact';
|
||||
|
||||
export interface HelpPageProps {
|
||||
content: HelpContent;
|
||||
appName: string;
|
||||
appId: string;
|
||||
translations: HelpPageTranslations;
|
||||
searchEnabled?: boolean;
|
||||
showFAQ?: boolean;
|
||||
showFeatures?: boolean;
|
||||
showShortcuts?: boolean;
|
||||
showGettingStarted?: boolean;
|
||||
showChangelog?: boolean;
|
||||
showContact?: boolean;
|
||||
defaultSection?: HelpSection;
|
||||
showBackButton?: boolean;
|
||||
onBack?: () => void;
|
||||
onSectionChange?: (section: HelpSection) => void;
|
||||
onSearch?: (query: string, results: SearchResult[]) => void;
|
||||
}
|
||||
|
||||
export interface FAQSectionProps {
|
||||
items: HelpContent['faq'];
|
||||
translations: Pick<HelpPageTranslations, 'faq' | 'common'>;
|
||||
showCategories?: boolean;
|
||||
maxItems?: number;
|
||||
expandFirst?: boolean;
|
||||
}
|
||||
|
||||
export interface FeaturesOverviewProps {
|
||||
items: HelpContent['features'];
|
||||
translations: Pick<HelpPageTranslations, 'features'>;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcutsProps {
|
||||
items: HelpContent['shortcuts'];
|
||||
translations: Pick<HelpPageTranslations, 'shortcuts'>;
|
||||
}
|
||||
|
||||
export interface GettingStartedGuideProps {
|
||||
items: HelpContent['gettingStarted'];
|
||||
translations: Pick<HelpPageTranslations, 'gettingStarted'>;
|
||||
}
|
||||
|
||||
export interface ChangelogSectionProps {
|
||||
items: HelpContent['changelog'];
|
||||
translations: Pick<HelpPageTranslations, 'changelog'>;
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
export interface ContactSectionProps {
|
||||
contact: HelpContent['contact'];
|
||||
translations: Pick<HelpPageTranslations, 'contact'>;
|
||||
}
|
||||
|
||||
export interface HelpSearchProps {
|
||||
content: HelpContent;
|
||||
translations: Pick<HelpPageTranslations, 'search'>;
|
||||
placeholder?: string;
|
||||
onResultSelect: (result: SearchResult) => void;
|
||||
}
|
||||
16
packages/shared-help-ui/tsconfig.json
Normal file
16
packages/shared-help-ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
@ -65,5 +65,18 @@ export {
|
|||
getForgotPasswordTranslations,
|
||||
} from './translations/auth';
|
||||
|
||||
// Help translations
|
||||
export {
|
||||
en as helpTranslationsEn,
|
||||
de as helpTranslationsDe,
|
||||
it as helpTranslationsIt,
|
||||
fr as helpTranslationsFr,
|
||||
es as helpTranslationsEs,
|
||||
type HelpTranslations,
|
||||
type HelpLocale,
|
||||
helpTranslations,
|
||||
getHelpTranslations,
|
||||
} from './translations/help';
|
||||
|
||||
// Components
|
||||
export { LanguageSelector } from './components';
|
||||
|
|
|
|||
65
packages/shared-i18n/src/translations/help/de.json
Normal file
65
packages/shared-i18n/src/translations/help/de.json
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"title": "Hilfe & Support",
|
||||
"subtitle": "Finde Antworten und lerne die App kennen",
|
||||
"searchPlaceholder": "Hilfe durchsuchen...",
|
||||
"sections": {
|
||||
"faq": "FAQ",
|
||||
"features": "Features",
|
||||
"shortcuts": "Tastenkürzel",
|
||||
"gettingStarted": "Erste Schritte",
|
||||
"changelog": "Neuigkeiten",
|
||||
"contact": "Kontakt"
|
||||
},
|
||||
"search": {
|
||||
"noResults": "Keine Ergebnisse für \"{query}\"",
|
||||
"resultsCount": "{count} Ergebnisse gefunden",
|
||||
"searching": "Suche..."
|
||||
},
|
||||
"faq": {
|
||||
"noItems": "Keine FAQs verfügbar",
|
||||
"categories": {
|
||||
"general": "Allgemein",
|
||||
"account": "Konto",
|
||||
"billing": "Abrechnung",
|
||||
"features": "Funktionen",
|
||||
"technical": "Technik",
|
||||
"privacy": "Datenschutz"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"noItems": "Noch keine Features dokumentiert",
|
||||
"comingSoon": "Demnächst",
|
||||
"learnMore": "Mehr erfahren"
|
||||
},
|
||||
"shortcuts": {
|
||||
"noItems": "Keine Tastenkürzel verfügbar"
|
||||
},
|
||||
"gettingStarted": {
|
||||
"noItems": "Noch keine Anleitungen verfügbar",
|
||||
"estimatedTime": "Geschätzte Zeit",
|
||||
"difficulty": {
|
||||
"beginner": "Anfänger",
|
||||
"intermediate": "Fortgeschritten",
|
||||
"advanced": "Experte"
|
||||
}
|
||||
},
|
||||
"changelog": {
|
||||
"noItems": "Noch keine Updates",
|
||||
"types": {
|
||||
"major": "Haupt-Update",
|
||||
"minor": "Kleines Update",
|
||||
"patch": "Fehlerbehebung",
|
||||
"beta": "Beta"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"noInfo": "Kontaktinformationen nicht verfügbar",
|
||||
"email": "E-Mail senden",
|
||||
"responseTime": "Antwortzeit"
|
||||
},
|
||||
"common": {
|
||||
"back": "Zurück",
|
||||
"showMore": "Mehr anzeigen",
|
||||
"showLess": "Weniger anzeigen"
|
||||
}
|
||||
}
|
||||
65
packages/shared-i18n/src/translations/help/en.json
Normal file
65
packages/shared-i18n/src/translations/help/en.json
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"title": "Help & Support",
|
||||
"subtitle": "Find answers and learn how to use the app",
|
||||
"searchPlaceholder": "Search help articles...",
|
||||
"sections": {
|
||||
"faq": "FAQ",
|
||||
"features": "Features",
|
||||
"shortcuts": "Keyboard Shortcuts",
|
||||
"gettingStarted": "Getting Started",
|
||||
"changelog": "What's New",
|
||||
"contact": "Contact Us"
|
||||
},
|
||||
"search": {
|
||||
"noResults": "No results found for \"{query}\"",
|
||||
"resultsCount": "{count} results found",
|
||||
"searching": "Searching..."
|
||||
},
|
||||
"faq": {
|
||||
"noItems": "No FAQs available",
|
||||
"categories": {
|
||||
"general": "General",
|
||||
"account": "Account",
|
||||
"billing": "Billing",
|
||||
"features": "Features",
|
||||
"technical": "Technical",
|
||||
"privacy": "Privacy"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"noItems": "No features documented yet",
|
||||
"comingSoon": "Coming soon",
|
||||
"learnMore": "Learn more"
|
||||
},
|
||||
"shortcuts": {
|
||||
"noItems": "No keyboard shortcuts available"
|
||||
},
|
||||
"gettingStarted": {
|
||||
"noItems": "No guides available yet",
|
||||
"estimatedTime": "Estimated time",
|
||||
"difficulty": {
|
||||
"beginner": "Beginner",
|
||||
"intermediate": "Intermediate",
|
||||
"advanced": "Advanced"
|
||||
}
|
||||
},
|
||||
"changelog": {
|
||||
"noItems": "No updates yet",
|
||||
"types": {
|
||||
"major": "Major",
|
||||
"minor": "Minor",
|
||||
"patch": "Patch",
|
||||
"beta": "Beta"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"noInfo": "Contact information not available",
|
||||
"email": "Email us",
|
||||
"responseTime": "Response time"
|
||||
},
|
||||
"common": {
|
||||
"back": "Back",
|
||||
"showMore": "Show more",
|
||||
"showLess": "Show less"
|
||||
}
|
||||
}
|
||||
65
packages/shared-i18n/src/translations/help/es.json
Normal file
65
packages/shared-i18n/src/translations/help/es.json
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"title": "Ayuda y Soporte",
|
||||
"subtitle": "Encuentra respuestas y aprende a usar la aplicación",
|
||||
"searchPlaceholder": "Buscar en la ayuda...",
|
||||
"sections": {
|
||||
"faq": "FAQ",
|
||||
"features": "Características",
|
||||
"shortcuts": "Atajos de teclado",
|
||||
"gettingStarted": "Primeros pasos",
|
||||
"changelog": "Novedades",
|
||||
"contact": "Contacto"
|
||||
},
|
||||
"search": {
|
||||
"noResults": "Sin resultados para \"{query}\"",
|
||||
"resultsCount": "{count} resultados encontrados",
|
||||
"searching": "Buscando..."
|
||||
},
|
||||
"faq": {
|
||||
"noItems": "No hay preguntas frecuentes disponibles",
|
||||
"categories": {
|
||||
"general": "General",
|
||||
"account": "Cuenta",
|
||||
"billing": "Facturación",
|
||||
"features": "Características",
|
||||
"technical": "Técnico",
|
||||
"privacy": "Privacidad"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"noItems": "No hay características documentadas",
|
||||
"comingSoon": "Próximamente",
|
||||
"learnMore": "Saber más"
|
||||
},
|
||||
"shortcuts": {
|
||||
"noItems": "No hay atajos de teclado disponibles"
|
||||
},
|
||||
"gettingStarted": {
|
||||
"noItems": "No hay guías disponibles",
|
||||
"estimatedTime": "Tiempo estimado",
|
||||
"difficulty": {
|
||||
"beginner": "Principiante",
|
||||
"intermediate": "Intermedio",
|
||||
"advanced": "Avanzado"
|
||||
}
|
||||
},
|
||||
"changelog": {
|
||||
"noItems": "Sin actualizaciones",
|
||||
"types": {
|
||||
"major": "Principal",
|
||||
"minor": "Menor",
|
||||
"patch": "Corrección",
|
||||
"beta": "Beta"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"noInfo": "Información de contacto no disponible",
|
||||
"email": "Envíanos un correo",
|
||||
"responseTime": "Tiempo de respuesta"
|
||||
},
|
||||
"common": {
|
||||
"back": "Volver",
|
||||
"showMore": "Ver más",
|
||||
"showLess": "Ver menos"
|
||||
}
|
||||
}
|
||||
65
packages/shared-i18n/src/translations/help/fr.json
Normal file
65
packages/shared-i18n/src/translations/help/fr.json
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"title": "Aide & Support",
|
||||
"subtitle": "Trouvez des réponses et apprenez à utiliser l'application",
|
||||
"searchPlaceholder": "Rechercher dans l'aide...",
|
||||
"sections": {
|
||||
"faq": "FAQ",
|
||||
"features": "Fonctionnalités",
|
||||
"shortcuts": "Raccourcis clavier",
|
||||
"gettingStarted": "Premiers pas",
|
||||
"changelog": "Nouveautés",
|
||||
"contact": "Contact"
|
||||
},
|
||||
"search": {
|
||||
"noResults": "Aucun résultat pour \"{query}\"",
|
||||
"resultsCount": "{count} résultats trouvés",
|
||||
"searching": "Recherche..."
|
||||
},
|
||||
"faq": {
|
||||
"noItems": "Aucune FAQ disponible",
|
||||
"categories": {
|
||||
"general": "Général",
|
||||
"account": "Compte",
|
||||
"billing": "Facturation",
|
||||
"features": "Fonctionnalités",
|
||||
"technical": "Technique",
|
||||
"privacy": "Confidentialité"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"noItems": "Aucune fonctionnalité documentée",
|
||||
"comingSoon": "Bientôt disponible",
|
||||
"learnMore": "En savoir plus"
|
||||
},
|
||||
"shortcuts": {
|
||||
"noItems": "Aucun raccourci clavier disponible"
|
||||
},
|
||||
"gettingStarted": {
|
||||
"noItems": "Aucun guide disponible",
|
||||
"estimatedTime": "Temps estimé",
|
||||
"difficulty": {
|
||||
"beginner": "Débutant",
|
||||
"intermediate": "Intermédiaire",
|
||||
"advanced": "Avancé"
|
||||
}
|
||||
},
|
||||
"changelog": {
|
||||
"noItems": "Aucune mise à jour",
|
||||
"types": {
|
||||
"major": "Majeure",
|
||||
"minor": "Mineure",
|
||||
"patch": "Correctif",
|
||||
"beta": "Bêta"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"noInfo": "Informations de contact non disponibles",
|
||||
"email": "Nous contacter",
|
||||
"responseTime": "Délai de réponse"
|
||||
},
|
||||
"common": {
|
||||
"back": "Retour",
|
||||
"showMore": "Voir plus",
|
||||
"showLess": "Voir moins"
|
||||
}
|
||||
}
|
||||
108
packages/shared-i18n/src/translations/help/index.ts
Normal file
108
packages/shared-i18n/src/translations/help/index.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Help translations exports
|
||||
*/
|
||||
|
||||
import en from './en.json';
|
||||
import de from './de.json';
|
||||
import it from './it.json';
|
||||
import fr from './fr.json';
|
||||
import es from './es.json';
|
||||
|
||||
export { en, de, it, fr, es };
|
||||
|
||||
/**
|
||||
* Help translations type structure
|
||||
*/
|
||||
export interface HelpTranslations {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
searchPlaceholder: string;
|
||||
sections: {
|
||||
faq: string;
|
||||
features: string;
|
||||
shortcuts: string;
|
||||
gettingStarted: string;
|
||||
changelog: string;
|
||||
contact: string;
|
||||
};
|
||||
search: {
|
||||
noResults: string;
|
||||
resultsCount: string;
|
||||
searching: string;
|
||||
};
|
||||
faq: {
|
||||
noItems: string;
|
||||
categories: {
|
||||
general: string;
|
||||
account: string;
|
||||
billing: string;
|
||||
features: string;
|
||||
technical: string;
|
||||
privacy: string;
|
||||
};
|
||||
};
|
||||
features: {
|
||||
noItems: string;
|
||||
comingSoon: string;
|
||||
learnMore: string;
|
||||
};
|
||||
shortcuts: {
|
||||
noItems: string;
|
||||
};
|
||||
gettingStarted: {
|
||||
noItems: string;
|
||||
estimatedTime: string;
|
||||
difficulty: {
|
||||
beginner: string;
|
||||
intermediate: string;
|
||||
advanced: string;
|
||||
};
|
||||
};
|
||||
changelog: {
|
||||
noItems: string;
|
||||
types: {
|
||||
major: string;
|
||||
minor: string;
|
||||
patch: string;
|
||||
beta: string;
|
||||
};
|
||||
};
|
||||
contact: {
|
||||
noInfo: string;
|
||||
email: string;
|
||||
responseTime: string;
|
||||
};
|
||||
common: {
|
||||
back: string;
|
||||
showMore: string;
|
||||
showLess: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Supported help locales
|
||||
*/
|
||||
export type HelpLocale = 'en' | 'de' | 'it' | 'fr' | 'es';
|
||||
|
||||
/**
|
||||
* All help translations by locale
|
||||
*/
|
||||
export const helpTranslations: Record<HelpLocale, HelpTranslations> = {
|
||||
en,
|
||||
de,
|
||||
it,
|
||||
fr,
|
||||
es,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get help translations by locale
|
||||
*/
|
||||
export function getHelpTranslations(locale: string): HelpTranslations {
|
||||
const supportedLocale = locale as HelpLocale;
|
||||
if (supportedLocale in helpTranslations) {
|
||||
return helpTranslations[supportedLocale];
|
||||
}
|
||||
// Default to English
|
||||
return helpTranslations.en;
|
||||
}
|
||||
65
packages/shared-i18n/src/translations/help/it.json
Normal file
65
packages/shared-i18n/src/translations/help/it.json
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
{
|
||||
"title": "Aiuto & Supporto",
|
||||
"subtitle": "Trova risposte e impara a usare l'app",
|
||||
"searchPlaceholder": "Cerca nell'aiuto...",
|
||||
"sections": {
|
||||
"faq": "FAQ",
|
||||
"features": "Funzionalità",
|
||||
"shortcuts": "Scorciatoie",
|
||||
"gettingStarted": "Primi passi",
|
||||
"changelog": "Novità",
|
||||
"contact": "Contatti"
|
||||
},
|
||||
"search": {
|
||||
"noResults": "Nessun risultato per \"{query}\"",
|
||||
"resultsCount": "{count} risultati trovati",
|
||||
"searching": "Ricerca..."
|
||||
},
|
||||
"faq": {
|
||||
"noItems": "Nessuna FAQ disponibile",
|
||||
"categories": {
|
||||
"general": "Generale",
|
||||
"account": "Account",
|
||||
"billing": "Fatturazione",
|
||||
"features": "Funzionalità",
|
||||
"technical": "Tecnico",
|
||||
"privacy": "Privacy"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"noItems": "Nessuna funzionalità documentata",
|
||||
"comingSoon": "Prossimamente",
|
||||
"learnMore": "Scopri di più"
|
||||
},
|
||||
"shortcuts": {
|
||||
"noItems": "Nessuna scorciatoia disponibile"
|
||||
},
|
||||
"gettingStarted": {
|
||||
"noItems": "Nessuna guida disponibile",
|
||||
"estimatedTime": "Tempo stimato",
|
||||
"difficulty": {
|
||||
"beginner": "Principiante",
|
||||
"intermediate": "Intermedio",
|
||||
"advanced": "Avanzato"
|
||||
}
|
||||
},
|
||||
"changelog": {
|
||||
"noItems": "Nessun aggiornamento",
|
||||
"types": {
|
||||
"major": "Principale",
|
||||
"minor": "Secondario",
|
||||
"patch": "Correzione",
|
||||
"beta": "Beta"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"noInfo": "Informazioni di contatto non disponibili",
|
||||
"email": "Inviaci un'email",
|
||||
"responseTime": "Tempo di risposta"
|
||||
},
|
||||
"common": {
|
||||
"back": "Indietro",
|
||||
"showMore": "Mostra di più",
|
||||
"showLess": "Mostra meno"
|
||||
}
|
||||
}
|
||||
932
pnpm-lock.yaml
generated
932
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue