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:
Till-JS 2025-12-09 20:24:22 +01:00
parent b6158a89a6
commit 1dda437192
60 changed files with 5482 additions and 0 deletions

View 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>

View 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

View 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

View 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.

View 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.

View 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).

View 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.

View 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.

View 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).

View file

@ -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.

View file

@ -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.

View 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"
}
}

View 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';

View 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;
}

View 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,
};
}

View 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() + '...';
}

View 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);
}

View 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"]
}

View 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"
}
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 });
},
};
}

View 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';

View 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>
);
}

View 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'>;
}

View 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"]
}

View 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"
}
}

View 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;
}

View 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';

View 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>;

View 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;
}

View 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"]
}

View 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"
}
}

View 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>

View file

@ -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}

View 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}

View 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>

View 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>

View 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>

View file

@ -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}

View file

@ -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}

View 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>

View file

@ -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}

View 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';

View 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>

View 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;
}

View 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"]
}

View file

@ -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';

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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"
}
}

View 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;
}

View 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

File diff suppressed because it is too large Load diff