Compare commits

..

85 Commits

Author SHA1 Message Date
Ahmed Darrazi
c7cc8d17b1 bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 5m53s
2025-12-20 23:59:00 +01:00
Ahmed Darrazi
2055484a87 bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m52s
2025-12-19 17:24:10 +01:00
Ahmed Darrazi
4e7fdb046d add certificate disbale function
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m49s
2025-12-19 16:55:49 +01:00
Ahmed Darrazi
7f12ab202f add certificate disbale function
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m54s
2025-12-19 16:50:55 +01:00
Ahmed Darrazi
368a49fb0c add certificate disbale function
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m49s
2025-12-19 16:30:50 +01:00
Ahmed Darrazi
cbf39f2feb bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m48s
2025-12-19 15:58:58 +01:00
Ahmed Darrazi
a09c33a762 bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m53s
2025-12-19 15:55:45 +01:00
Ahmed Darrazi
1b8be73219 bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m48s
2025-12-19 15:37:20 +01:00
Ahmed Darrazi
3d67eedf0c bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 2m10s
2025-12-19 01:37:38 +01:00
Ahmed Darrazi
e4d0ac9072 bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 4m22s
2025-12-19 01:20:45 +01:00
Ahmed Darrazi
c483ab88b5 bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m50s
2025-12-19 01:13:10 +01:00
Ahmed Darrazi
d9753a7fc8 bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 2m14s
2025-12-19 01:06:40 +01:00
Ahmed Darrazi
70a970cee6 unlimited quizzes and exams
All checks were successful
Build & Push Docker Image / docker (push) Successful in 3m22s
2025-12-19 00:59:28 +01:00
Ahmed Darrazi
5b4470a323 lang bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 2m9s
2025-12-19 00:11:10 +01:00
Ahmed Darrazi
6ba9651f34 lang bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m50s
2025-12-18 23:35:27 +01:00
Ahmed Darrazi
fd6ca8221f some bugfixes
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m51s
2025-12-18 23:09:34 +01:00
Ahmed Darrazi
60cc9db469 some bugfixes
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m50s
2025-12-18 22:57:40 +01:00
Ahmed Darrazi
da407e95e8 some bugfixes
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m51s
2025-12-18 22:49:22 +01:00
Ahmed Darrazi
52e935b0d0 optional wishlist cart exam
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m50s
2025-12-18 22:28:00 +01:00
Ahmed Darrazi
141f4da7e9 bugfix zertifikate
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m53s
2025-12-18 22:11:00 +01:00
Ahmed Darrazi
dedcb041c5 Übersetze NotificationController Nachricht ins Deutsche
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m54s
2025-12-18 22:01:18 +01:00
Ahmed Darrazi
4a76f46b6b Übersetze CheckRole Fehlermeldung ins Deutsche
Some checks failed
Build & Push Docker Image / docker (push) Has been cancelled
2025-12-18 22:00:58 +01:00
Ahmed Darrazi
1f0dff07a9 bugfix notify text
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m49s
2025-12-18 21:47:54 +01:00
Ahmed Darrazi
8df9ea6802 bugfix coupons
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m49s
2025-12-18 21:45:13 +01:00
Ahmed Darrazi
0aa2081a1d Übersetze Mail-Vorlagen ins Deutsche (du-Form)
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m49s
2025-12-18 21:33:01 +01:00
Ahmed Darrazi
bf507baaff lang bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m51s
2025-12-18 21:17:40 +01:00
Ahmed Darrazi
f892054e53 lang bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m50s
2025-12-18 21:06:02 +01:00
Ahmed Darrazi
4aa98c1921 lang bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m48s
2025-12-18 20:59:36 +01:00
Ahmed Darrazi
7b5bd97215 lang bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m49s
2025-12-18 20:47:19 +01:00
Ahmed Darrazi
ed003be546 added add user function with verify
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m49s
2025-12-18 20:38:09 +01:00
Ahmed Darrazi
6fba63daf5 added add user function
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m49s
2025-12-18 18:10:01 +01:00
Ahmed Darrazi
731e226a9f redirect to /login
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m50s
2025-12-18 17:27:41 +01:00
Ahmed Darrazi
138cf3566b Fix mixed content by trusting proxy HTTPS
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m50s
2025-12-18 15:35:51 +01:00
Ahmed Darrazi
c00f58bced Optimize mobile load by lazy loading large modules
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m48s
2025-12-18 15:23:00 +01:00
Ahmed Darrazi
af68304404 performance bugfixing
All checks were successful
Build & Push Docker Image / docker (push) Successful in 5m28s
2025-12-18 12:58:39 +01:00
Ahmed Darrazi
d1515064dd lang bugfix
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m54s
2025-12-17 23:19:17 +01:00
Ahmed Darrazi
c7c6a4b987 bugfixes
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m51s
2025-12-17 21:13:35 +01:00
Ahmed Darrazi
a67c3cf65f bugfixes
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m47s
2025-12-17 14:48:09 +01:00
Ahmed Darrazi
dee9bbfbe7 bugfixes
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m50s
2025-12-17 14:37:16 +01:00
Ahmed Darrazi
efc7e1c2a7 bugfixes
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m48s
2025-12-17 14:23:58 +01:00
Ahmed Darrazi
b381624f12 bugfixes
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m49s
2025-12-17 14:15:13 +01:00
Ahmed Darrazi
6e9a2438c5 bugfixes
All checks were successful
Build & Push Docker Image / docker (push) Successful in 1m46s
2025-12-17 14:03:50 +01:00
Ahmed Darrazi
aaec2867b4 bugfixes
All checks were successful
Build & Push Docker Image / docker (push) Successful in 2m3s
2025-12-17 13:22:29 +01:00
Ahmed Darrazi
fa5b9318f2 bugfixes
Some checks are pending
Build & Push Docker Image / docker (push) Waiting to run
2025-12-17 13:21:32 +01:00
Ahmed Darrazi
e949f7e175 bugfixes
Some checks are pending
Build & Push Docker Image / docker (push) Waiting to run
2025-12-17 13:18:33 +01:00
Ahmed Darrazi
a11eeb94c7 bugfixes
Some checks are pending
Build & Push Docker Image / docker (push) Waiting to run
2025-12-17 13:16:04 +01:00
Ahmed Darrazi
96014dc222 bugfixes 2025-12-17 12:45:11 +01:00
Ahmed Darrazi
aa83dd1436 bugfixes 2025-12-17 00:27:48 +01:00
Ahmed Darrazi
50c7e9562b bugfixes 2025-12-16 23:56:53 +01:00
Ahmed Darrazi
d84c9f9c8a bugfixes 2025-12-16 23:46:42 +01:00
Ahmed Darrazi
e102b4ef16 bugfixes 2025-12-16 22:16:06 +01:00
Ahmed Darrazi
6063568fcd bugfixes 2025-12-16 22:10:35 +01:00
Ahmed Darrazi
0d95b57e9d bugfixes 2025-12-16 22:02:10 +01:00
Ahmed Darrazi
ca5b42a209 bugfixes 2025-12-16 21:32:29 +01:00
Ahmed Darrazi
ed0135cab5 bugfixes 2025-12-16 21:27:48 +01:00
Ahmed Darrazi
b9fdd33fa4 bugfixes 2025-12-16 21:21:20 +01:00
Ahmed Darrazi
1fb7b6f69b bugfixes 2025-12-16 21:17:49 +01:00
Ahmed Darrazi
0859306ba2 bugfixes 2025-12-16 20:22:02 +01:00
Ahmed Darrazi
932a7bf753 bugfixes 2025-12-16 20:13:41 +01:00
Ahmed Darrazi
b252a20f87 bugfixes 2025-12-16 19:47:40 +01:00
Ahmed Darrazi
b142c9b88a bugfixes 2025-12-16 15:45:14 +01:00
Ahmed Darrazi
0afa950e40 bugfixes 2025-12-16 15:27:21 +01:00
Ahmed Darrazi
99cb79d773 bugfixes 2025-12-16 15:13:35 +01:00
Ahmed Darrazi
6b0880ec8a bugfixes 2025-12-16 14:52:53 +01:00
Ahmed Darrazi
64e11e9d33 bugfixes 2025-12-16 14:41:35 +01:00
Ahmed Darrazi
7191b7ac96 bugfixes 2025-12-16 14:21:52 +01:00
Ahmed Darrazi
cad04cf9f9 bugfixes 2025-12-16 13:25:46 +01:00
Ahmed Darrazi
0fa5fcdab1 bugfixes 2025-12-16 13:21:09 +01:00
Ahmed Darrazi
ee12a5191d bugfixes 2025-12-16 13:13:42 +01:00
Ahmed Darrazi
3b6eb1d2a3 bugfixes 2025-12-16 11:19:15 +01:00
Ahmed Darrazi
9347c16413 bugfixes 2025-12-16 11:00:54 +01:00
Ahmed Darrazi
d757cdfc7d bugfixes 2025-12-16 10:45:49 +01:00
Ahmed Darrazi
3a763d2183 chore(i18n): revert docker-compose change (remove startup seeder command) 2025-12-15 23:29:43 +01:00
Ahmed Darrazi
df9287d36e chore(i18n): add ImportDeLanguageSeeder and run seeder on container start 2025-12-15 22:54:35 +01:00
Ahmed Darrazi
96b5fa0091 chore(i18n): update generated German lang files 2025-12-15 22:46:40 +01:00
Ahmed Darrazi
e3489d506e chore(i18n): add German language files generated (heuristic translations) and generator script 2025-12-15 22:32:26 +01:00
7ad5212c8a Update docker-compose.yml 2025-12-15 15:07:41 +00:00
81bf0ed667 Update docker-compose.yml 2025-12-15 15:01:27 +00:00
6cee2e355e Update docker-compose.yml 2025-12-15 14:55:43 +00:00
Ahmed Darrazi
34c544f696 fix: remove leftover merge marker from docker-compose.yml (valid YAML) 2025-12-15 15:40:32 +01:00
Ahmed Darrazi
faaa9153e9 chore: merge 001-add-dokploy-deploy — resolve docker-compose conflict (use Traefik labels, retain image/env/restart) 2025-12-15 15:38:08 +01:00
Ahmed Darrazi
b562ad2d49 chore: add root docker-compose without host ports; Traefik handles routing 2025-12-15 15:27:25 +01:00
c44bcc85b6 Merge pull request 'docs(spec): add deploy key example to deployment-credentials and prepare tasks' (#2) from 001-add-dokploy-deploy into main
Reviewed-on: #2
2025-12-15 12:56:48 +00:00
5959109797 Merge pull request '001-run-checks' (#1) from 001-run-checks into main
Reviewed-on: #1
2025-12-15 12:56:31 +00:00
Ahmed Darrazi
f32e849632 docs(spec): add deploy key example to deployment-credentials and prepare tasks 2025-12-15 13:55:53 +01:00
151 changed files with 5223 additions and 780 deletions

View File

@ -0,0 +1,184 @@
---
description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation.
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Goal
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit.tasks` has successfully produced a complete `tasks.md`.
## Operating Constraints
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit.analyze`.
## Execution Steps
### 1. Initialize Analysis Context
Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
- SPEC = FEATURE_DIR/spec.md
- PLAN = FEATURE_DIR/plan.md
- TASKS = FEATURE_DIR/tasks.md
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
### 2. Load Artifacts (Progressive Disclosure)
Load only the minimal necessary context from each artifact:
**From spec.md:**
- Overview/Context
- Functional Requirements
- Non-Functional Requirements
- User Stories
- Edge Cases (if present)
**From plan.md:**
- Architecture/stack choices
- Data Model references
- Phases
- Technical constraints
**From tasks.md:**
- Task IDs
- Descriptions
- Phase grouping
- Parallel markers [P]
- Referenced file paths
**From constitution:**
- Load `.specify/memory/constitution.md` for principle validation
### 3. Build Semantic Models
Create internal representations (do not include raw artifacts in output):
- **Requirements inventory**: Each functional + non-functional requirement with a stable key (derive slug based on imperative phrase; e.g., "User can upload file" → `user-can-upload-file`)
- **User story/action inventory**: Discrete user actions with acceptance criteria
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
### 4. Detection Passes (Token-Efficient Analysis)
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
#### A. Duplication Detection
- Identify near-duplicate requirements
- Mark lower-quality phrasing for consolidation
#### B. Ambiguity Detection
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
#### C. Underspecification
- Requirements with verbs but missing object or measurable outcome
- User stories missing acceptance criteria alignment
- Tasks referencing files or components not defined in spec/plan
#### D. Constitution Alignment
- Any requirement or plan element conflicting with a MUST principle
- Missing mandated sections or quality gates from constitution
#### E. Coverage Gaps
- Requirements with zero associated tasks
- Tasks with no mapped requirement/story
- Non-functional requirements not reflected in tasks (e.g., performance, security)
#### F. Inconsistency
- Terminology drift (same concept named differently across files)
- Data entities referenced in plan but absent in spec (or vice versa)
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
### 5. Severity Assignment
Use this heuristic to prioritize findings:
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
### 6. Produce Compact Analysis Report
Output a Markdown report (no file writes) with the following structure:
## Specification Analysis Report
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|----|----------|----------|-------------|---------|----------------|
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
(Add one row per finding; generate stable IDs prefixed by category initial.)
**Coverage Summary Table:**
| Requirement Key | Has Task? | Task IDs | Notes |
|-----------------|-----------|----------|-------|
**Constitution Alignment Issues:** (if any)
**Unmapped Tasks:** (if any)
**Metrics:**
- Total Requirements
- Total Tasks
- Coverage % (requirements with >=1 task)
- Ambiguity Count
- Duplication Count
- Critical Issues Count
### 7. Provide Next Actions
At end of report, output a concise Next Actions block:
- If CRITICAL issues exist: Recommend resolving before `/speckit.implement`
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
- Provide explicit command suggestions: e.g., "Run /speckit.specify with refinement", "Run /speckit.plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
### 8. Offer Remediation
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
## Operating Principles
### Context Efficiency
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
### Analysis Guidelines
- **NEVER modify files** (this is read-only analysis)
- **NEVER hallucinate missing sections** (if absent, report them accurately)
- **Prioritize constitution violations** (these are always CRITICAL)
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
- **Report zero issues gracefully** (emit success report with coverage statistics)
## Context
$ARGUMENTS

View File

@ -0,0 +1,294 @@
---
description: Generate a custom checklist for the current feature based on user requirements.
---
## Checklist Purpose: "Unit Tests for English"
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
**NOT for verification/testing**:
- ❌ NOT "Verify the button clicks correctly"
- ❌ NOT "Test error handling works"
- ❌ NOT "Confirm the API returns 200"
- ❌ NOT checking if code/implementation matches the spec
**FOR requirements quality validation**:
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Execution Steps
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
- All file paths must be absolute.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
- Only ask about information that materially changes checklist content
- Be skipped individually if already unambiguous in `$ARGUMENTS`
- Prefer precision over breadth
Generation algorithm:
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
5. Formulate questions chosen from these archetypes:
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
Question formatting rules:
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
- Limit to AE options maximum; omit table if a free-form answer is clearer
- Never ask the user to restate what they already said
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
Defaults when interaction impossible:
- Depth: Standard
- Audience: Reviewer (PR) if code-related; Author otherwise
- Focus: Top 2 relevance clusters
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted followups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
- Derive checklist theme (e.g., security, review, deploy, ux)
- Consolidate explicit must-have items mentioned by user
- Map focus selections to category scaffolding
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
4. **Load feature context**: Read from FEATURE_DIR:
- spec.md: Feature requirements and scope
- plan.md (if exists): Technical details, dependencies
- tasks.md (if exists): Implementation tasks
**Context Loading Strategy**:
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
- Prefer summarizing long sections into concise scenario/requirement bullets
- Use progressive disclosure: add follow-on retrieval only if gaps detected
- If source docs are large, generate interim summary items instead of embedding raw text
5. **Generate checklist** - Create "Unit Tests for Requirements":
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
- Generate unique checklist filename:
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
- Format: `[domain].md`
- If file exists, append to existing file
- Number items sequentially starting from CHK001
- Each `/speckit.checklist` run creates a NEW file (never overwrites existing checklists)
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
- **Completeness**: Are all necessary requirements present?
- **Clarity**: Are requirements unambiguous and specific?
- **Consistency**: Do requirements align with each other?
- **Measurability**: Can requirements be objectively verified?
- **Coverage**: Are all scenarios/edge cases addressed?
**Category Structure** - Group items by requirement quality dimensions:
- **Requirement Completeness** (Are all necessary requirements documented?)
- **Requirement Clarity** (Are requirements specific and unambiguous?)
- **Requirement Consistency** (Do requirements align without conflicts?)
- **Acceptance Criteria Quality** (Are success criteria measurable?)
- **Scenario Coverage** (Are all flows/cases addressed?)
- **Edge Case Coverage** (Are boundary conditions defined?)
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
- **Dependencies & Assumptions** (Are they documented and validated?)
- **Ambiguities & Conflicts** (What needs clarification?)
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
**WRONG** (Testing implementation):
- "Verify landing page displays 3 episode cards"
- "Test hover states work on desktop"
- "Confirm logo click navigates home"
**CORRECT** (Testing requirements quality):
- "Are the exact number and layout of featured episodes specified?" [Completeness]
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
- "Are loading states defined for asynchronous episode data?" [Completeness]
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
**ITEM STRUCTURE**:
Each item should follow this pattern:
- Question format asking about requirement quality
- Focus on what's WRITTEN (or not written) in the spec/plan
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
- Reference spec section `[Spec §X.Y]` when checking existing requirements
- Use `[Gap]` marker when checking for missing requirements
**EXAMPLES BY QUALITY DIMENSION**:
Completeness:
- "Are error handling requirements defined for all API failure modes? [Gap]"
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
Clarity:
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
Consistency:
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
Coverage:
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
Measurability:
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
**Scenario Classification & Coverage** (Requirements Quality Focus):
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
**Traceability Requirements**:
- MINIMUM: ≥80% of items MUST include at least one traceability reference
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
**Surface & Resolve Issues** (Requirements Quality Problems):
Ask questions about the requirements themselves:
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
**Content Consolidation**:
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
- Merge near-duplicates checking the same requirement aspect
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
- ❌ References to code execution, user actions, system behavior
- ❌ "Displays correctly", "works properly", "functions as expected"
- ❌ "Click", "navigate", "render", "load", "execute"
- ❌ Test cases, test plans, QA procedures
- ❌ Implementation details (frameworks, APIs, algorithms)
**✅ REQUIRED PATTERNS** - These test requirements quality:
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
- ✅ "Are requirements consistent between [section A] and [section B]?"
- ✅ "Can [requirement] be objectively measured/verified?"
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
- ✅ "Does the spec define [missing aspect]?"
6. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
7. **Report**: Output full path to created checklist, item count, and remind user that each run creates a new file. Summarize:
- Focus areas selected
- Depth level
- Actor/timing
- Any explicit user-specified must-have items incorporated
**Important**: Each `/speckit.checklist` command invocation creates a checklist file using short, descriptive names unless file already exists. This allows:
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
- Simple, memorable filenames that indicate checklist purpose
- Easy identification and navigation in the `checklists/` folder
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
## Example Checklist Types & Sample Items
**UX Requirements Quality:** `ux.md`
Sample items (testing the requirements, NOT the implementation):
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
**API Requirements Quality:** `api.md`
Sample items:
- "Are error response formats specified for all failure scenarios? [Completeness]"
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
- "Are authentication requirements consistent across all endpoints? [Consistency]"
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
- "Is versioning strategy documented in requirements? [Gap]"
**Performance Requirements Quality:** `performance.md`
Sample items:
- "Are performance requirements quantified with specific metrics? [Clarity]"
- "Are performance targets defined for all critical user journeys? [Coverage]"
- "Are performance requirements under different load conditions specified? [Completeness]"
- "Can performance requirements be objectively measured? [Measurability]"
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
**Security Requirements Quality:** `security.md`
Sample items:
- "Are authentication requirements specified for all protected resources? [Coverage]"
- "Are data protection requirements defined for sensitive information? [Completeness]"
- "Is the threat model documented and requirements aligned to it? [Traceability]"
- "Are security requirements consistent with compliance obligations? [Consistency]"
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
## Anti-Examples: What NOT To Do
**❌ WRONG - These test implementation, not requirements:**
```markdown
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
```
**✅ CORRECT - These test requirements quality:**
```markdown
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
```
**Key Differences:**
- Wrong: Tests if the system works correctly
- Correct: Tests if the requirements are written correctly
- Wrong: Verification of behavior
- Correct: Validation of requirement quality
- Wrong: "Does it do X?"
- Correct: "Is X clearly specified?"

View File

@ -0,0 +1,181 @@
---
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
handoffs:
- label: Build Technical Plan
agent: speckit.plan
prompt: Create a plan for the spec. I am building with...
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit.plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
Execution steps:
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
- `FEATURE_DIR`
- `FEATURE_SPEC`
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
- If JSON parsing fails, abort and instruct user to re-run `/speckit.specify` or verify feature branch environment.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
Functional Scope & Behavior:
- Core user goals & success criteria
- Explicit out-of-scope declarations
- User roles / personas differentiation
Domain & Data Model:
- Entities, attributes, relationships
- Identity & uniqueness rules
- Lifecycle/state transitions
- Data volume / scale assumptions
Interaction & UX Flow:
- Critical user journeys / sequences
- Error/empty/loading states
- Accessibility or localization notes
Non-Functional Quality Attributes:
- Performance (latency, throughput targets)
- Scalability (horizontal/vertical, limits)
- Reliability & availability (uptime, recovery expectations)
- Observability (logging, metrics, tracing signals)
- Security & privacy (authN/Z, data protection, threat assumptions)
- Compliance / regulatory constraints (if any)
Integration & External Dependencies:
- External services/APIs and failure modes
- Data import/export formats
- Protocol/versioning assumptions
Edge Cases & Failure Handling:
- Negative scenarios
- Rate limiting / throttling
- Conflict resolution (e.g., concurrent edits)
Constraints & Tradeoffs:
- Technical constraints (language, storage, hosting)
- Explicit tradeoffs or rejected alternatives
Terminology & Consistency:
- Canonical glossary terms
- Avoided synonyms / deprecated terms
Completion Signals:
- Acceptance criteria testability
- Measurable Definition of Done style indicators
Misc / Placeholders:
- TODO markers / unresolved decisions
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
For each category with Partial or Missing status, add a candidate question opportunity unless:
- Clarification would not materially change implementation or validation strategy
- Information is better deferred to planning phase (note internally)
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
- Maximum of 10 total questions across the whole session.
- Each question must be answerable with EITHER:
- A short multiplechoice selection (25 distinct, mutually exclusive options), OR
- A one-word / shortphrase answer (explicitly constrain: "Answer in <=5 words").
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
4. Sequential questioning loop (interactive):
- Present EXACTLY ONE question at a time.
- For multiplechoice questions:
- **Analyze all options** and determine the **most suitable option** based on:
- Best practices for the project type
- Common patterns in similar implementations
- Risk reduction (security, performance, maintainability)
- Alignment with any explicit project goals or constraints visible in the spec
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
- Format as: `**Recommended:** Option [X] - <reasoning>`
- Then render all options as a Markdown table:
| Option | Description |
|--------|-------------|
| A | <Option A description> |
| B | <Option B description> |
| C | <Option C description> (add D/E as needed up to 5) |
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
- For shortanswer style (no meaningful discrete options):
- Provide your **suggested answer** based on best practices and context.
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
- After the user answers:
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
- Stop asking further questions when:
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
- User signals completion ("done", "good", "no more"), OR
- You reach 5 asked questions.
- Never reveal future queued questions in advance.
- If no valid questions exist at start, immediately report no critical ambiguities.
5. Integration after EACH accepted answer (incremental update approach):
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
- For the first integrated answer in this session:
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
- Then immediately apply the clarification to the most appropriate section(s):
- Functional ambiguity → Update or add a bullet in Functional Requirements.
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
- Non-functional constraint → Add/modify measurable criteria in Non-Functional / Quality Attributes section (convert vague adjective to metric or explicit target).
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
- Keep each inserted clarification minimal and testable (avoid narrative drift).
6. Validation (performed after EACH write plus final pass):
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
- Total asked (accepted) questions ≤ 5.
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
- Terminology consistency: same canonical term used across all updated sections.
7. Write the updated spec back to `FEATURE_SPEC`.
8. Report completion (after questioning loop ends or early termination):
- Number of questions asked & answered.
- Path to updated spec.
- Sections touched (list names).
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit.plan` or run `/speckit.clarify` again later post-plan.
- Suggested next command.
Behavior rules:
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
- If spec file missing, instruct user to run `/speckit.specify` first (do not create a new spec here).
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
- Respect user early termination signals ("stop", "done", "proceed").
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
Context for prioritization: $ARGUMENTS

View File

@ -0,0 +1,82 @@
---
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
handoffs:
- label: Build Specification
agent: speckit.specify
prompt: Implement the feature specification based on the updated constitution. I want to build...
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
Follow this execution flow:
1. Load the existing constitution template at `.specify/memory/constitution.md`.
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
2. Collect/derive values for placeholders:
- If user input (conversation) supplies a value, use it.
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
- MINOR: New principle/section added or materially expanded guidance.
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
- If version bump type ambiguous, propose reasoning before finalizing.
3. Draft the updated constitution content:
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing nonnegotiable rules, explicit rationale if not obvious.
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
4. Consistency propagation checklist (convert prior checklist into active validations):
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
- Version change: old → new
- List of modified principles (old title → new title if renamed)
- Added sections
- Removed sections
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
- Follow-up TODOs if any placeholders intentionally deferred.
6. Validation before final output:
- No remaining unexplained bracket tokens.
- Version line matches report.
- Dates ISO format YYYY-MM-DD.
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
8. Output a final summary to the user with:
- New version and bump rationale.
- Any files flagged for manual follow-up.
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
Formatting & Style Requirements:
- Use Markdown headings exactly as in the template (do not demote/promote levels).
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
- Keep a single blank line between sections.
- Avoid trailing whitespace.
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.

View File

@ -0,0 +1,135 @@
---
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
- Scan all checklist files in the checklists/ directory
- For each checklist, count:
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
- Completed items: Lines matching `- [X]` or `- [x]`
- Incomplete items: Lines matching `- [ ]`
- Create a status table:
```text
| Checklist | Total | Completed | Incomplete | Status |
|-----------|-------|-----------|------------|--------|
| ux.md | 12 | 12 | 0 | ✓ PASS |
| test.md | 8 | 5 | 3 | ✗ FAIL |
| security.md | 6 | 6 | 0 | ✓ PASS |
```
- Calculate overall status:
- **PASS**: All checklists have 0 incomplete items
- **FAIL**: One or more checklists have incomplete items
- **If any checklist is incomplete**:
- Display the table with incomplete item counts
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)"
- Wait for user response before continuing
- If user says "no" or "wait" or "stop", halt execution
- If user says "yes" or "proceed" or "continue", proceed to step 3
- **If all checklists are complete**:
- Display the table showing all checklists passed
- Automatically proceed to step 3
3. Load and analyze the implementation context:
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
- **IF EXISTS**: Read data-model.md for entities and relationships
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
- **IF EXISTS**: Read research.md for technical decisions and constraints
- **IF EXISTS**: Read quickstart.md for integration scenarios
4. **Project Setup Verification**:
- **REQUIRED**: Create/verify ignore files based on actual project setup:
**Detection & Creation Logic**:
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
```sh
git rev-parse --git-dir 2>/dev/null
```
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
- Check if .eslintrc* exists → create/verify .eslintignore
- Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns
- Check if .prettierrc* exists → create/verify .prettierignore
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
- Check if terraform files (*.tf) exist → create/verify .terraformignore
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
**If ignore file missing**: Create with full pattern set for detected technology
**Common Patterns by Technology** (from plan.md tech stack):
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `Makefile`, `config.log`, `.idea/`, `*.log`, `.env*`
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
**Tool-Specific Patterns**:
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
5. Parse tasks.md structure and extract:
- **Task phases**: Setup, Tests, Core, Integration, Polish
- **Task dependencies**: Sequential vs parallel execution rules
- **Task details**: ID, description, file paths, parallel markers [P]
- **Execution flow**: Order and dependency requirements
6. Execute implementation following the task plan:
- **Phase-by-phase execution**: Complete each phase before moving to the next
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
- **File-based coordination**: Tasks affecting the same files must run sequentially
- **Validation checkpoints**: Verify each phase completion before proceeding
7. Implementation execution rules:
- **Setup first**: Initialize project structure, dependencies, configuration
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
- **Core development**: Implement models, services, CLI commands, endpoints
- **Integration work**: Database connections, middleware, logging, external services
- **Polish and validation**: Unit tests, performance optimization, documentation
8. Progress tracking and error handling:
- Report progress after each completed task
- Halt execution if any non-parallel task fails
- For parallel tasks [P], continue with successful tasks, report failed ones
- Provide clear error messages with context for debugging
- Suggest next steps if implementation cannot proceed
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
9. Completion validation:
- Verify all required tasks are completed
- Check that implemented features match the original specification
- Validate that tests pass and coverage meets requirements
- Confirm the implementation follows the technical plan
- Report final status with summary of completed work
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit.tasks` first to regenerate the task list.

View File

@ -0,0 +1,89 @@
---
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
handoffs:
- label: Create Tasks
agent: speckit.tasks
prompt: Break the plan into tasks
send: true
- label: Create Checklist
agent: speckit.checklist
prompt: Create a checklist for the following domain...
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
- Fill Constitution Check section from constitution
- Evaluate gates (ERROR if violations unjustified)
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
- Phase 1: Generate data-model.md, contracts/, quickstart.md
- Phase 1: Update agent context by running the agent script
- Re-evaluate Constitution Check post-design
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
## Phases
### Phase 0: Outline & Research
1. **Extract unknowns from Technical Context** above:
- For each NEEDS CLARIFICATION → research task
- For each dependency → best practices task
- For each integration → patterns task
2. **Generate and dispatch research agents**:
```text
For each unknown in Technical Context:
Task: "Research {unknown} for {feature context}"
For each technology choice:
Task: "Find best practices for {tech} in {domain}"
```
3. **Consolidate findings** in `research.md` using format:
- Decision: [what was chosen]
- Rationale: [why chosen]
- Alternatives considered: [what else evaluated]
**Output**: research.md with all NEEDS CLARIFICATION resolved
### Phase 1: Design & Contracts
**Prerequisites:** `research.md` complete
1. **Extract entities from feature spec**`data-model.md`:
- Entity name, fields, relationships
- Validation rules from requirements
- State transitions if applicable
2. **Generate API contracts** from functional requirements:
- For each user action → endpoint
- Use standard REST/GraphQL patterns
- Output OpenAPI/GraphQL schema to `/contracts/`
3. **Agent context update**:
- Run `.specify/scripts/bash/update-agent-context.sh codex`
- These scripts detect which AI agent is in use
- Update the appropriate agent-specific context file
- Add only new technology from current plan
- Preserve manual additions between markers
**Output**: data-model.md, /contracts/*, quickstart.md, agent-specific file
## Key rules
- Use absolute paths
- ERROR on gate failures or unresolved clarifications

View File

@ -0,0 +1,258 @@
---
description: Create or update the feature specification from a natural language feature description.
handoffs:
- label: Build Technical Plan
agent: speckit.plan
prompt: Create a plan for the spec. I am building with...
- label: Clarify Spec Requirements
agent: speckit.clarify
prompt: Clarify specification requirements
send: true
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
The text the user typed after `/speckit.specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
Given that feature description, do this:
1. **Generate a concise short name** (2-4 words) for the branch:
- Analyze the feature description and extract the most meaningful keywords
- Create a 2-4 word short name that captures the essence of the feature
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
- Keep it concise but descriptive enough to understand the feature at a glance
- Examples:
- "I want to add user authentication" → "user-auth"
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
- "Create a dashboard for analytics" → "analytics-dashboard"
- "Fix payment processing timeout bug" → "fix-payment-timeout"
2. **Check for existing branches before creating new one**:
a. First, fetch all remote branches to ensure we have the latest information:
```bash
git fetch --all --prune
```
b. Find the highest feature number across all sources for the short-name:
- Remote branches: `git ls-remote --heads origin | grep -E 'refs/heads/[0-9]+-<short-name>$'`
- Local branches: `git branch | grep -E '^[* ]*[0-9]+-<short-name>$'`
- Specs directories: Check for directories matching `specs/[0-9]+-<short-name>`
c. Determine the next available number:
- Extract all numbers from all three sources
- Find the highest number N
- Use N+1 for the new branch number
d. Run the script `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS"` with the calculated number and short-name:
- Pass `--number N+1` and `--short-name "your-short-name"` along with the feature description
- Bash example: `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS" --json --number 5 --short-name "user-auth" "Add user authentication"`
- PowerShell example: `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS" -Json -Number 5 -ShortName "user-auth" "Add user authentication"`
**IMPORTANT**:
- Check all three sources (remote branches, local branches, specs directories) to find the highest number
- Only match branches/directories with the exact short-name pattern
- If no existing branches/directories found with this short-name, start with number 1
- You must only ever run this script once per feature
- The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for
- The JSON output will contain BRANCH_NAME and SPEC_FILE paths
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot")
3. Load `.specify/templates/spec-template.md` to understand required sections.
4. Follow this execution flow:
1. Parse user description from Input
If empty: ERROR "No feature description provided"
2. Extract key concepts from description
Identify: actors, actions, data, constraints
3. For unclear aspects:
- Make informed guesses based on context and industry standards
- Only mark with [NEEDS CLARIFICATION: specific question] if:
- The choice significantly impacts feature scope or user experience
- Multiple reasonable interpretations exist with different implications
- No reasonable default exists
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
4. Fill User Scenarios & Testing section
If no clear user flow: ERROR "Cannot determine user scenarios"
5. Generate Functional Requirements
Each requirement must be testable
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
6. Define Success Criteria
Create measurable, technology-agnostic outcomes
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
Each criterion must be verifiable without implementation details
7. Identify Key Entities (if data involved)
8. Return: SUCCESS (spec ready for planning)
5. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
6. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
a. **Create Spec Quality Checklist**: Generate a checklist file at `FEATURE_DIR/checklists/requirements.md` using the checklist template structure with these validation items:
```markdown
# Specification Quality Checklist: [FEATURE NAME]
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: [DATE]
**Feature**: [Link to spec.md]
## Content Quality
- [ ] No implementation details (languages, frameworks, APIs)
- [ ] Focused on user value and business needs
- [ ] Written for non-technical stakeholders
- [ ] All mandatory sections completed
## Requirement Completeness
- [ ] No [NEEDS CLARIFICATION] markers remain
- [ ] Requirements are testable and unambiguous
- [ ] Success criteria are measurable
- [ ] Success criteria are technology-agnostic (no implementation details)
- [ ] All acceptance scenarios are defined
- [ ] Edge cases are identified
- [ ] Scope is clearly bounded
- [ ] Dependencies and assumptions identified
## Feature Readiness
- [ ] All functional requirements have clear acceptance criteria
- [ ] User scenarios cover primary flows
- [ ] Feature meets measurable outcomes defined in Success Criteria
- [ ] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
```
b. **Run Validation Check**: Review the spec against each checklist item:
- For each item, determine if it passes or fails
- Document specific issues found (quote relevant spec sections)
c. **Handle Validation Results**:
- **If all items pass**: Mark checklist complete and proceed to step 6
- **If items fail (excluding [NEEDS CLARIFICATION])**:
1. List the failing items and specific issues
2. Update the spec to address each issue
3. Re-run validation until all items pass (max 3 iterations)
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
- **If [NEEDS CLARIFICATION] markers remain**:
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
3. For each clarification needed (max 3), present options to user in this format:
```markdown
## Question [N]: [Topic]
**Context**: [Quote relevant spec section]
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
**Suggested Answers**:
| Option | Answer | Implications |
|--------|--------|--------------|
| A | [First suggested answer] | [What this means for the feature] |
| B | [Second suggested answer] | [What this means for the feature] |
| C | [Third suggested answer] | [What this means for the feature] |
| Custom | Provide your own answer | [Explain how to provide custom input] |
**Your choice**: _[Wait for user response]_
```
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
- Use consistent spacing with pipes aligned
- Each cell should have spaces around content: `| Content |` not `|Content|`
- Header separator must have at least 3 dashes: `|--------|`
- Test that the table renders correctly in markdown preview
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
6. Present all questions together before waiting for responses
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
9. Re-run validation after all clarifications are resolved
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
7. Report completion with branch name, spec file path, checklist results, and readiness for the next phase (`/speckit.clarify` or `/speckit.plan`).
**NOTE:** The script creates and checks out the new branch and initializes the spec file before writing.
## General Guidelines
## Quick Guidelines
- Focus on **WHAT** users need and **WHY**.
- Avoid HOW to implement (no tech stack, APIs, code structure).
- Written for business stakeholders, not developers.
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
### Section Requirements
- **Mandatory sections**: Must be completed for every feature
- **Optional sections**: Include only when relevant to the feature
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
### For AI Generation
When creating this spec from a user prompt:
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
- Significantly impact feature scope or user experience
- Have multiple reasonable interpretations with different implications
- Lack any reasonable default
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
6. **Common areas needing clarification** (only if no reasonable default exists):
- Feature scope and boundaries (include/exclude specific use cases)
- User types and permissions (if multiple conflicting interpretations possible)
- Security/compliance requirements (when legally/financially significant)
**Examples of reasonable defaults** (don't ask about these):
- Data retention: Industry-standard practices for the domain
- Performance targets: Standard web/mobile app expectations unless specified
- Error handling: User-friendly messages with appropriate fallbacks
- Authentication method: Standard session-based or OAuth2 for web apps
- Integration patterns: RESTful APIs unless specified otherwise
### Success Criteria Guidelines
Success criteria must be:
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
4. **Verifiable**: Can be tested/validated without knowing implementation details
**Good examples**:
- "Users can complete checkout in under 3 minutes"
- "System supports 10,000 concurrent users"
- "95% of searches return results in under 1 second"
- "Task completion rate improves by 40%"
**Bad examples** (implementation-focused):
- "API response time is under 200ms" (too technical, use "Users see results instantly")
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
- "React components render efficiently" (framework-specific)
- "Redis cache hit rate above 80%" (technology-specific)

View File

@ -0,0 +1,137 @@
---
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
handoffs:
- label: Analyze For Consistency
agent: speckit.analyze
prompt: Run a project analysis for consistency
send: true
- label: Implement Project
agent: speckit.implement
prompt: Start the implementation in phases
send: true
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load design documents**: Read from FEATURE_DIR:
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
- **Optional**: data-model.md (entities), contracts/ (API endpoints), research.md (decisions), quickstart.md (test scenarios)
- Note: Not all projects have all documents. Generate tasks based on what's available.
3. **Execute task generation workflow**:
- Load plan.md and extract tech stack, libraries, project structure
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
- If data-model.md exists: Extract entities and map to user stories
- If contracts/ exists: Map endpoints to user stories
- If research.md exists: Extract decisions for setup tasks
- Generate tasks organized by user story (see Task Generation Rules below)
- Generate dependency graph showing user story completion order
- Create parallel execution examples per user story
- Validate task completeness (each user story has all needed tasks, independently testable)
4. **Generate tasks.md**: Use `.specify/templates/tasks-template.md` as structure, fill with:
- Correct feature name from plan.md
- Phase 1: Setup tasks (project initialization)
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
- Phase 3+: One phase per user story (in priority order from spec.md)
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
- Final Phase: Polish & cross-cutting concerns
- All tasks must follow the strict checklist format (see Task Generation Rules below)
- Clear file paths for each task
- Dependencies section showing story completion order
- Parallel execution examples per story
- Implementation strategy section (MVP first, incremental delivery)
5. **Report**: Output path to generated tasks.md and summary:
- Total task count
- Task count per user story
- Parallel opportunities identified
- Independent test criteria for each story
- Suggested MVP scope (typically just User Story 1)
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
Context for task generation: $ARGUMENTS
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
## Task Generation Rules
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
### Checklist Format (REQUIRED)
Every task MUST strictly follow this format:
```text
- [ ] [TaskID] [P?] [Story?] Description with file path
```
**Format Components**:
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
4. **[Story] label**: REQUIRED for user story phase tasks only
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
- Setup phase: NO story label
- Foundational phase: NO story label
- User Story phases: MUST have story label
- Polish phase: NO story label
5. **Description**: Clear action with exact file path
**Examples**:
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
### Task Organization
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
- Each user story (P1, P2, P3...) gets its own phase
- Map all related components to their story:
- Models needed for that story
- Services needed for that story
- Endpoints/UI needed for that story
- If tests requested: Tests specific to that story
- Mark story dependencies (most stories should be independent)
2. **From Contracts**:
- Map each contract/endpoint → to the user story it serves
- If tests requested: Each contract → contract test task [P] before implementation in that story's phase
3. **From Data Model**:
- Map each entity to the user story(ies) that need it
- If entity serves multiple stories: Put in earliest story or Setup phase
- Relationships → service layer tasks in appropriate story phase
4. **From Setup/Infrastructure**:
- Shared infrastructure → Setup phase (Phase 1)
- Foundational/blocking tasks → Foundational phase (Phase 2)
- Story-specific setup → within that story's phase
### Phase Structure
- **Phase 1**: Setup (project initialization)
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
- Each phase should be a complete, independently testable increment
- **Final Phase**: Polish & Cross-Cutting Concerns

View File

@ -0,0 +1,30 @@
---
description: Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts.
tools: ['github/github-mcp-server/issue_write']
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Outline
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
1. From the executed script, extract the path to **tasks**.
1. Get the Git remote by running:
```bash
git config --get remote.origin.url
```
> [!CAUTION]
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote.
> [!CAUTION]
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL

View File

@ -0,0 +1,32 @@
name: Build & Push Docker Image
on:
push:
branches:
- main
jobs:
docker:
runs-on: cloudarix
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Login to Gitea Container Registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.cloudarix.de \
-u "${{ secrets.REGISTRY_USER }}" \
--password-stdin
- name: Build Docker image
run: |
docker build \
-t git.cloudarix.de/ahmido/lms:${{ github.sha }} \
-t git.cloudarix.de/ahmido/lms:latest \
.
- name: Push Docker image
run: |
docker push git.cloudarix.de/ahmido/lms:${{ github.sha }}
docker push git.cloudarix.de/ahmido/lms:latest

162
Dockerfile.old Normal file
View File

@ -0,0 +1,162 @@
# Multi-stage Dockerfile for Laravel Mentor LMS
FROM node:22-alpine AS node-builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install Node dependencies (including dev dependencies for build)
RUN npm ci
# Copy all source files for build (Vite may need access to various files)
COPY . .
# Build assets
RUN npm run build
# PHP Stage
FROM php:8.3-fpm-alpine AS php-base
# Install system dependencies
RUN apk add --no-cache \
bash \
curl \
freetype-dev \
g++ \
gcc \
git \
icu-dev \
jpeg-dev \
libpng-dev \
libzip-dev \
make \
mysql-client \
nginx \
oniguruma-dev \
redis \
supervisor \
unzip \
zip \
autoconf \
pkgconf \
linux-headers \
openssl \
ca-certificates
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) \
bcmath \
exif \
gd \
intl \
mbstring \
opcache \
pcntl \
pdo \
pdo_mysql \
zip
# Install Redis extension with proper configuration
RUN pecl config-set php_ini /usr/local/etc/php/php.ini \
&& pecl install redis \
&& docker-php-ext-enable redis
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www/html
# Copy PHP configuration
COPY docker/php/php.ini /usr/local/etc/php/conf.d/99-custom.ini
COPY docker/php/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf
# Copy Nginx configuration
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
COPY docker/nginx/default.conf /etc/nginx/http.d/default.conf
# Copy Supervisor configuration
COPY docker/supervisor/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Copy application code
COPY --chown=www-data:www-data . .
# Remove conflicting file causing PSR-4 autoloading errors
RUN rm -f Modules/Exam/app/Services/Exam-ResourceService.php
# Copy built assets from node stage
COPY --from=node-builder --chown=www-data:www-data /app/public/build ./public/build
# Copy environment file from docker.env to .env if .env doesn't exist
# RUN if [ ! -f .env ]; then cp docker.env .env; fi
# Generate APP_KEY if not present (required for some Composer operations)
RUN if ! grep -q "APP_KEY=base64:" .env; then \
php artisan key:generate --no-interaction || \
echo "APP_KEY=base64:$(openssl rand -base64 32)" >> .env; \
fi
# Create necessary directories before Composer install
RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views \
&& mkdir -p bootstrap/cache
# Debug: Check Composer and PHP setup
RUN composer --version && php --version
# Configure Composer
ENV COMPOSER_ALLOW_SUPERUSER=1
ENV COMPOSER_NO_INTERACTION=1
ENV COMPOSER_MEMORY_LIMIT=2G
# Update composer.lock to match PHP version and install dependencies
# Update symfony packages to resolve security advisories
RUN composer update symfony/http-foundation symfony/http-kernel --with-all-dependencies --no-dev --optimize-autoloader --no-interaction --verbose \
&& composer install --no-dev --optimize-autoloader --no-interaction --verbose
# Set permissions
RUN chown -R www-data:www-data /var/www/html \
&& chmod -R 755 /var/www/html/storage \
&& chmod -R 755 /var/www/html/bootstrap/cache
# Create necessary directories
RUN mkdir -p /var/www/html/storage/logs \
&& mkdir -p /var/www/html/storage/framework/cache \
&& mkdir -p /var/www/html/storage/framework/sessions \
&& mkdir -p /var/www/html/storage/framework/views \
&& mkdir -p /var/www/html/storage/app/public \
&& mkdir -p /var/log/supervisor \
&& mkdir -p /var/log/nginx \
&& mkdir -p /var/run \
&& chown -R www-data:www-data /var/www/html/storage
# Fix Nginx temp directories permissions for file uploads
RUN chown -R www-data:www-data /var/lib/nginx/ \
&& chmod -R 755 /var/lib/nginx/tmp
# Create storage symlink and test files
RUN php artisan storage:link || echo "Storage link creation failed, but continuing..." \
&& echo '<?php echo "Laravel Test: " . date("Y-m-d H:i:s") . "<br>PHP Version: " . phpversion(); ?>' > /var/www/html/public/test.php \
&& echo 'healthy' > /var/www/html/public/health \
&& echo '<?php echo "Installer should redirect to: /install/step-1<br>"; echo "Routes available: "; echo "<pre>"; print_r(glob("/var/www/html/Modules/*/routes/*.php")); echo "</pre>"; ?>' > /var/www/html/public/debug.php \
&& chmod 644 /var/www/html/public/test.php /var/www/html/public/health /var/www/html/public/debug.php
# Ensure the app is NOT marked as installed (for first-time setup)
# RUN rm -f /var/www/html/public/installed
# Clear any cached routes/config that might interfere with installation
RUN php artisan config:clear || true \
&& php artisan route:clear || true \
&& php artisan view:clear || true
# Expose ports
EXPOSE 80
# Health check (more lenient)
HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=5 \
CMD curl -f http://localhost:80/health || curl -f http://localhost:80/test.php || exit 1
# Start supervisor
CMD ["sh", "-c", "mkdir -p storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache storage/app && touch storage/installed storage/app/installed public/installed && chown -R www-data:www-data storage bootstrap/cache public/installed && chmod -R 775 storage bootstrap/cache && if [ -f .env ]; then chmod 666 .env || true; fi && php artisan migrate --force && php artisan optimize:clear && /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf"]

View File

@ -20,17 +20,17 @@ class CertificateController extends Controller
if (!$activeTemplate) {
$activeTemplate = [
'id' => 0,
'name' => 'Default Template',
'name' => 'Standardvorlage',
'logo_path' => null,
'template_data' => [
'primaryColor' => '#3730a3',
'secondaryColor' => '#4b5563',
'backgroundColor' => '#dbeafe',
'borderColor' => '#f59e0b',
'titleText' => 'Certificate of Completion',
'descriptionText' => 'This certificate is proudly presented to',
'completionText' => 'for successfully completing the course',
'footerText' => 'Authorized Certificate',
'titleText' => 'Zertifikat über den Abschluss',
'descriptionText' => 'Dieses Zertifikat wird feierlich überreicht an',
'completionText' => 'für den erfolgreichen Abschluss des Kurses',
'footerText' => 'Offizielles Zertifikat',
'fontFamily' => 'serif',
],
'is_active' => false,

View File

@ -0,0 +1,30 @@
<?php
namespace Modules\Certificate\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use Illuminate\Http\Request;
class CertificationSettingsController extends Controller
{
public function update(Request $request)
{
$validated = $request->validate([
'show_course_certificate' => ['sometimes', 'boolean'],
'show_course_marksheet' => ['sometimes', 'boolean'],
]);
$system = Setting::where('type', 'system')->firstOrFail();
$fields = $system->fields ?? [];
foreach ($validated as $key => $value) {
$fields[$key] = (bool) $value;
}
$system->update(['fields' => $fields]);
return back()->with('success', 'Certification settings updated successfully.');
}
}

View File

@ -25,9 +25,14 @@ class CertificateService extends MediaService
{
$template = CertificateTemplate::findOrFail($id);
$updateData = $data;
unset($updateData['logo']);
$template->update($updateData);
if (array_key_exists('logo', $data) && $data['logo']) {
$template->update([
'logo_path' => $this->addNewDeletePrev($template, $data['logo'], 'certificates_logo')
'logo_path' => $this->addNewDeletePrev($template, $data['logo'], 'certificates_logo'),
]);
}

View File

@ -23,10 +23,10 @@ class CertificateTemplateSeeder extends Seeder
'secondaryColor' => '#475569',
'backgroundColor' => '#eff6ff',
'borderColor' => '#2563eb',
'titleText' => 'Certificate of Achievement',
'descriptionText' => 'This certificate is proudly presented to',
'completionText' => 'for successfully completing the course',
'footerText' => 'Authorized and Certified',
'titleText' => 'Leistungszertifikat',
'descriptionText' => 'Dieses Zertifikat wird feierlich überreicht an',
'completionText' => 'für den erfolgreichen Abschluss des Kurses',
'footerText' => 'Offiziell beglaubigt',
'fontFamily' => 'sans-serif',
],
'is_active' => true,
@ -44,10 +44,10 @@ class CertificateTemplateSeeder extends Seeder
'secondaryColor' => '#1f2937',
'backgroundColor' => '#d1fae5',
'borderColor' => '#10b981',
'titleText' => 'Certificate of Excellence',
'descriptionText' => 'This is to certify that',
'completionText' => 'has demonstrated outstanding achievement in',
'footerText' => 'Congratulations on your accomplishment',
'titleText' => 'Exzellenzzertifikat',
'descriptionText' => 'Hiermit wird bescheinigt, dass',
'completionText' => 'hat herausragende Leistungen in gezeigt',
'footerText' => 'Herzlichen Glückwunsch zu Ihrer Leistung',
'fontFamily' => 'serif',
],
'is_active' => false,
@ -65,10 +65,10 @@ class CertificateTemplateSeeder extends Seeder
'secondaryColor' => '#374151',
'backgroundColor' => '#fae8ff',
'borderColor' => '#c026d3',
'titleText' => 'Certificate of Completion',
'descriptionText' => 'This prestigious certificate is awarded to',
'completionText' => 'for exceptional dedication and successful completion of',
'footerText' => 'Excellence in Learning',
'titleText' => 'Zertifikat über den Abschluss',
'descriptionText' => 'Dieses renommierte Zertifikat wird verliehen an',
'completionText' => 'für außergewöhnliches Engagement und erfolgreichen Abschluss von',
'footerText' => 'Exzellenz im Lernen',
'fontFamily' => 'cursive',
],
'is_active' => false,
@ -86,10 +86,10 @@ class CertificateTemplateSeeder extends Seeder
'secondaryColor' => '#1f2937',
'backgroundColor' => '#fef2f2',
'borderColor' => '#ef4444',
'titleText' => 'Certificate of Examination Excellence',
'descriptionText' => 'This certificate is proudly presented to',
'completionText' => 'for outstanding performance in the examination',
'footerText' => 'Authorized Examination Certificate',
'titleText' => 'Zertifikat für herausragende Prüfungsleistung',
'descriptionText' => 'Dieses Zertifikat wird feierlich überreicht an',
'completionText' => 'für herausragende Leistungen in der Prüfung',
'footerText' => 'Offizielles Prüfungszertifikat',
'fontFamily' => 'sans-serif',
],
'is_active' => true,
@ -107,10 +107,10 @@ class CertificateTemplateSeeder extends Seeder
'secondaryColor' => '#374151',
'backgroundColor' => '#fff7ed',
'borderColor' => '#f97316',
'titleText' => 'Certificate of Assessment Achievement',
'descriptionText' => 'This is to certify that',
'completionText' => 'has successfully passed the assessment with distinction',
'footerText' => 'Verified Assessment Certificate',
'titleText' => 'Zertifikat für Bewertungserfolg',
'descriptionText' => 'Hiermit wird bescheinigt, dass',
'completionText' => 'hat die Bewertung mit Auszeichnung bestanden',
'footerText' => 'Verifiziertes Bewertungszertifikat',
'fontFamily' => 'serif',
],
'is_active' => false,
@ -128,10 +128,10 @@ class CertificateTemplateSeeder extends Seeder
'secondaryColor' => '#1f2937',
'backgroundColor' => '#f0fdfa',
'borderColor' => '#14b8a6',
'titleText' => 'Certificate of Test Excellence',
'descriptionText' => 'This prestigious certificate is awarded to',
'completionText' => 'for exceptional performance in the test',
'footerText' => 'Certified Test Achievement',
'titleText' => 'Zertifikat für herausragende Testergebnisse',
'descriptionText' => 'Dieses renommierte Zertifikat wird verliehen an',
'completionText' => 'für außergewöhnliche Leistungen im Test',
'footerText' => 'Zertifikat für herausragende Testergebnisse',
'fontFamily' => 'cursive',
],
'is_active' => false,

View File

@ -2,12 +2,15 @@
use Illuminate\Support\Facades\Route;
use Modules\Certificate\Http\Controllers\CertificateController;
use Modules\Certificate\Http\Controllers\CertificationSettingsController;
use Modules\Certificate\Http\Controllers\CertificateTemplateController;
use Modules\Certificate\Http\Controllers\MarksheetTemplateController;
Route::middleware(['auth', 'verified'])->group(function () {
// Admin routes for managing certificate templates
Route::middleware(['role:admin'])->prefix('dashboard/certification')->group(function () {
Route::post('settings', [CertificationSettingsController::class, 'update'])->name('certification.settings.update');
Route::resource('certificate', CertificateTemplateController::class)->except(['show', 'create', 'update'])->names('certificate.templates');
Route::get('certificate/create-certificate', [CertificateTemplateController::class, 'create'])->name('certificate.templates.create');
Route::post('certificate/{id}', [CertificateTemplateController::class, 'update'])->name('certificate.templates.update');

View File

@ -24,7 +24,7 @@ class ExamRequest extends FormRequest
'duration_minutes' => request('duration_minutes') ? (int) request('duration_minutes') : 0,
'pass_mark' => request('pass_mark') ? (float) request('pass_mark') : null,
'total_marks' => request('total_marks') ? (float) request('total_marks') : null,
'max_attempts' => request('max_attempts') ? (int) request('max_attempts') : null,
'max_attempts' => request()->has('max_attempts') ? (int) request('max_attempts') : 0,
]);
}
@ -59,7 +59,7 @@ class ExamRequest extends FormRequest
'duration_minutes' => 'required|integer|min:0|max:59',
'pass_mark' => 'required|numeric|min:0|max:100',
'total_marks' => 'required|numeric|min:1',
'max_attempts' => 'required|integer|min:1',
'max_attempts' => 'required|integer|min:0',
// Status & Level
'status' => 'nullable|string|in:draft,published,archived',

View File

@ -19,7 +19,7 @@ class UpdateExamRequest extends FormRequest
'duration_minutes' => request('duration_minutes') ? (int) request('duration_minutes') : 0,
'pass_mark' => request('pass_mark') ? (float) request('pass_mark') : null,
'total_marks' => request('total_marks') ? (float) request('total_marks') : null,
'max_attempts' => request('max_attempts') ? (int) request('max_attempts') : null,
'max_attempts' => request()->has('max_attempts') ? (int) request('max_attempts') : 0,
]);
}
@ -103,7 +103,7 @@ class UpdateExamRequest extends FormRequest
'duration_minutes' => 'required|integer|min:0|max:59',
'pass_mark' => 'required|numeric|min:0|max:100',
'total_marks' => 'required|numeric|min:1',
'max_attempts' => 'required|integer|min:1',
'max_attempts' => 'required|integer|min:0',
];
}

View File

@ -19,7 +19,9 @@ class ExamAttemptService
->where('exam_id', $exam->id)
->count();
if ($previousAttempts >= $exam->max_attempts) {
$hasAttemptLimit = $exam->max_attempts > 0;
if ($hasAttemptLimit && $previousAttempts >= $exam->max_attempts) {
return null;
}

View File

@ -112,7 +112,9 @@ class ExamEnrollmentService extends MediaService
'enrollment' => $enrollment,
'is_active' => $enrollment->isActive(),
'attempts_used' => $attempts->count(),
'attempts_remaining' => max(0, $exam->max_attempts - $attempts->count()),
'attempts_remaining' => $exam->max_attempts > 0
? max(0, $exam->max_attempts - $attempts->count())
: null,
'completed_attempts' => $completedAttempts,
'best_score' => $bestScore,
'has_passed' => $hasPassed,

View File

@ -54,6 +54,13 @@ class LanguageController extends Controller
return back()->with('success', "Language deleted successfully");
}
public function sync(string $local)
{
$this->languageService->syncLanguageFromFiles($local);
return back()->with('success', "Language synced from files successfully");
}
public function edit_property(string $id)
{
$property = LanguageProperty::where('id', $id)->with(['language:id,code'])->firstOrFail();
@ -63,9 +70,13 @@ class LanguageController extends Controller
public function update_property(Request $request, $id)
{
$property = LanguageProperty::findOrFail($id);
$property = LanguageProperty::with('language:id,code')->findOrFail($id);
$property->update(['properties' => $request->all()]);
if ($property->language) {
$this->languageService->forgetLanguageCache($property->language->code);
}
return back()->with('success', $property->name . ' translation successfully updated');
}
@ -78,8 +89,10 @@ class LanguageController extends Controller
public function change_lang(Request $request)
{
Cache::forget($this->languageService->cacheKey);
$cookie = Cookie::forever('locale', $request->locale);
$locale = $request->locale;
$this->languageService->forgetLanguageCache($locale);
$cookie = Cookie::forever('locale', $locale);
return back()->withCookie($cookie);
}

View File

@ -3,11 +3,11 @@
namespace Modules\Language\Services;
use App\Services\MediaService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Lang;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Lang;
use Modules\Language\Models\Language;
use Modules\Language\Models\LanguageProperty;
use Exception;
@ -15,6 +15,7 @@ use Exception;
class LanguageService extends MediaService
{
public string $cacheKey;
protected array $groupNames = ['auth', 'button', 'common', 'dashboard', 'frontend', 'input', 'settings', 'table'];
public function __construct(Request $request)
{
@ -27,54 +28,181 @@ class LanguageService extends MediaService
$langDir = $langPath . "/" . $data['code'];
$appLangPath = storage_path('app/lang/default');
if (is_dir($langDir)) {
throw new Exception("Language already exist");
}
$groups = [
'auth' => require storage_path('app/lang/groups/auth.php'),
'button' => require storage_path('app/lang/groups/button.php'),
'common' => require storage_path('app/lang/groups/common.php'),
'dashboard' => require storage_path('app/lang/groups/dashboard.php'),
'frontend' => require storage_path('app/lang/groups/frontend.php'),
'input' => require storage_path('app/lang/groups/input.php'),
'settings' => require storage_path('app/lang/groups/settings.php'),
];
$language = Language::create($data);
$this->syncLanguagePropertiesFromFiles($language);
$languages = Language::create($data);
foreach ($groups as $key => $group) {
foreach ($group as $value) {
LanguageProperty::create([
...$value,
'group' => $key,
'language_id' => $languages->id,
]);
}
}
$alreadyExists = is_dir($langDir);
if (! $alreadyExists) {
File::makeDirectory($langDir, 0777, true, true);
File::copyDirectory($appLangPath, $langDir);
}
}
function updateLanguage($id, $data)
{
Language::find($id)->update($data);
}
public function syncLanguageFromFiles(string $locale): void
{
$language = Language::where('code', $locale)->first();
if (! $language) {
return;
}
$this->syncLanguagePropertiesFromFiles($language);
$this->forgetLanguageCache($locale);
}
protected function loadLanguageGroup(string $locale, string $groupName): array
{
$defaultGroupPath = storage_path("app/lang/groups/{$groupName}.php");
$defaultGroups = file_exists($defaultGroupPath) ? require $defaultGroupPath : [];
$repoGroupPath = base_path("lang/{$locale}/groups/{$groupName}.php");
if (file_exists($repoGroupPath)) {
return require $repoGroupPath;
}
$flatFilePath = base_path("lang/{$locale}/{$groupName}.php");
if (file_exists($flatFilePath)) {
$flatTranslations = require $flatFilePath;
return $this->mergeFlatTranslations($defaultGroups, $flatTranslations, $groupName);
}
return $defaultGroups;
}
protected function mergeFlatTranslations(array $defaultGroups, array $flatTranslations, string $groupName): array
{
if (empty($defaultGroups)) {
return [[
'name' => ucfirst(str_replace('_', ' ', $groupName)),
'slug' => $groupName,
'properties' => $flatTranslations,
]];
}
$groups = [];
foreach ($defaultGroups as $group) {
$properties = [];
foreach ($group['properties'] as $key => $value) {
if (array_key_exists($key, $flatTranslations)) {
$properties[$key] = $flatTranslations[$key];
unset($flatTranslations[$key]);
} else {
$properties[$key] = $value;
}
}
$groups[] = [
...$group,
'properties' => $properties,
];
}
if (! empty($flatTranslations)) {
$groups[] = [
'name' => ucfirst(str_replace('_', ' ', $groupName)) . ' Extra',
'slug' => "{$groupName}_extra",
'properties' => $flatTranslations,
];
}
return $groups;
}
protected function syncLanguagePropertiesFromFiles(Language $language): void
{
foreach ($this->groupNames as $groupName) {
$groups = $this->loadLanguageGroup($language->code, $groupName);
foreach ($groups as $group) {
LanguageProperty::updateOrCreate(
[
'language_id' => $language->id,
'group' => $groupName,
'slug' => $group['slug'],
],
[
'name' => $group['name'],
'properties' => $group['properties'],
]
);
}
}
}
function defaultLanguage($id)
{
Language::where('is_default', true)->update(['is_default' => false]);
Language::where('id', $id)->update(['is_default' => true]);
}
protected function getCacheKey(string $locale): string
{
return "{$this->cacheKey}_{$locale}";
}
public function forgetLanguageCache(string $locale): void
{
Cache::forget($this->getCacheKey($locale));
}
protected function getDefaultLocale(): string
{
$default = Language::where('is_default', true)->value('code');
if ($default) {
return $default;
}
return config('app.locale', 'en');
}
protected function buildTranslationsFromFiles(string $locale): array
{
$langPath = lang_path($locale);
if (! is_dir($langPath)) {
return [];
}
$translations = [];
foreach (File::allFiles($langPath) as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$group = $file->getFilenameWithoutExtension();
$values = require $file->getPathname();
foreach (Arr::dot($values) as $key => $value) {
$translations[$group . '.' . $key] = $value;
}
}
return $translations;
}
function setLanguageProperties(string $locale): void
{
$cached = Cache::rememberForever($this->cacheKey, function () use ($locale) {
$cacheKey = $this->getCacheKey($locale);
$cached = Cache::rememberForever($cacheKey, function () use ($locale) {
$language = Language::where('code', $locale)
->with('properties')
->first();
if (! $language) {
return [];
}
$groups = [];
foreach ($language->properties as $property) {
$groups[$property->group] = array_merge($groups[$property->group] ?? [], $property->properties);
@ -90,6 +218,10 @@ class LanguageService extends MediaService
return $translations;
});
if (empty($cached)) {
$cached = $this->buildTranslationsFromFiles($locale);
}
Lang::addLines($cached, $locale);
}

View File

@ -9,6 +9,7 @@ Route::post('change-direction', [LanguageController::class, 'change_direction'])
Route::middleware(['installed', 'appConfig', 'auth', 'role:admin'])->prefix('dashboard/settings')->group(function () {
Route::resource('language', LanguageController::class)->except(['create']);
Route::post('language/default/{id}', [LanguageController::class, 'default'])->name('language.default');
Route::post('language/{local}/sync', [LanguageController::class, 'sync'])->name('language.sync');
Route::get('/language/property/{property}', [LanguageController::class, 'edit_property'])->name('language.property.edit');
Route::put('/language/property/{property}', [LanguageController::class, 'update_property'])->name('language.property.update');

View File

@ -34,7 +34,7 @@ class EmailVerificationNotificationController extends Controller
{
$this->accountService->changeEmail($request->validated(), Auth::user()->id);
return back()->with('success', 'We have sent a email verification link to your new email account.');
return back()->with('success', 'Wir haben einen Bestätigungslink an deine neue E-Mail-Adresse gesendet.');
}
/**
@ -45,7 +45,7 @@ class EmailVerificationNotificationController extends Controller
$user = Auth::user();
$saved = $this->accountService->saveChangedEmail($request->token, $user->id);
$flash = $saved ? 'success' : 'error';
$message = $saved ? "New email successfully changed." : "Verification token didn't match or expire.";
$message = $saved ? 'Die EMail wurde erfolgreich geändert.' : 'Der Bestätigungs-Token stimmt nicht oder ist abgelaufen.';
if ($user->role == 'student') {
return redirect()->route('student.index', ['tab' => 'settings'])

View File

@ -48,7 +48,7 @@ class PasswordResetLinkController extends Controller
$user->notify(new ResetPasswordNotification($token));
}
return back()->with('status', __('A reset link will be sent if the account exists.'));
return back()->with('status', __('Ein Link zum Zurücksetzen wird gesendet, wenn das Konto existiert.'));
}
/**

View File

@ -4,39 +4,52 @@ namespace App\Http\Controllers\Auth;
use App\Enums\UserType;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Password;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
* Mark the user's email address as verified via signed link.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
public function __invoke(Request $request): RedirectResponse
{
$adminDashboard = route('dashboard', absolute: false) . '?verified=1';
$studentDashboard = route('student.index', ['tab' => 'courses'], absolute: false) . '?verified=1';
$user = User::find($request->route('id'));
if ($request->user()->hasVerifiedEmail()) {
if ($request->user()->role === UserType::STUDENT->value) {
return redirect()->intended($studentDashboard);
} else {
return redirect()->intended($adminDashboard);
}
if (!$user) {
abort(404);
}
if ($request->user()->markEmailAsVerified()) {
/** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */
$user = $request->user();
if (!hash_equals(sha1($user->getEmailForVerification()), (string) $request->route('hash'))) {
abort(403);
}
if (!$user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
event(new Verified($user));
}
if ($request->user()->role === UserType::STUDENT->value) {
return redirect()->intended($studentDashboard);
} else {
return redirect()->intended($adminDashboard);
}
if ($request->boolean('invite')) {
$token = Password::createToken($user);
return redirect()->route('password.reset', [
'token' => $token,
'email' => $user->email,
]);
}
if (Auth::check()) {
$redirect = Auth::user()->role === UserType::STUDENT->value
? route('student.index', ['tab' => 'courses'], absolute: false)
: route('dashboard', absolute: false);
return redirect()->intended($redirect . '?verified=1');
}
return redirect()->route('login')->with('status', __('auth.email_verified_successfully'));
}
}

View File

@ -26,9 +26,16 @@ class CourseCartController extends Controller
*/
public function index(Request $request)
{
$system = app('system_settings');
if (($system?->fields['show_course_cart'] ?? true) === false) {
abort(404);
}
$coupon = null;
if ($request->has('coupon')) {
$coupon = $this->couponService->getCoupon($request->coupon);
$couponCode = $request->input('coupon');
if (!empty($couponCode)) {
$coupon = $this->couponService->getCoupon($couponCode);
if (!$coupon) {
return back()->with('error', 'This coupon is not valid.');
@ -54,6 +61,11 @@ class CourseCartController extends Controller
*/
public function store(Request $request)
{
$system = app('system_settings');
if (($system?->fields['show_course_cart'] ?? true) === false) {
abort(404);
}
$this->cartService->addToCart(Auth::user()->id, $request->course_id);
return redirect(route('course-cart.index'))->with('success', 'Course added to cart successfully.');
@ -64,6 +76,11 @@ class CourseCartController extends Controller
*/
public function update(Request $request, CourseCart $courseCart)
{
$system = app('system_settings');
if (($system?->fields['show_course_cart'] ?? true) === false) {
abort(404);
}
$this->cartService->clearCart(Auth::user()->id);
return back()->with('success', 'Cart updated successfully.');
@ -74,6 +91,11 @@ class CourseCartController extends Controller
*/
public function destroy($course_cart)
{
$system = app('system_settings');
if (($system?->fields['show_course_cart'] ?? true) === false) {
abort(404);
}
$this->cartService->removeFromCart(Auth::user()->id, $course_cart);
return back()->with('success', 'Course removed from cart successfully.');

View File

@ -20,7 +20,7 @@ class CourseForumController extends Controller
{
$this->forumService->createForum($request->validated());
return back()->with('success', 'Forum created successfully');
return back()->with('success', 'Forum erfolgreich erstellt.');
}
/**
@ -30,7 +30,7 @@ class CourseForumController extends Controller
{
$this->forumService->updateForum($id, $request->validated());
return back()->with('success', 'Forum updated successfully');
return back()->with('success', 'Forum erfolgreich aktualisiert.');
}
/**
@ -40,6 +40,6 @@ class CourseForumController extends Controller
{
$this->forumService->deleteForum($id);
return back()->with('success', 'Forum deleted successfully');
return back()->with('success', 'Forum erfolgreich gelöscht.');
}
}

View File

@ -10,11 +10,74 @@ use App\Services\Course\CourseReviewService;
use App\Services\Course\CourseService;
use App\Services\Course\CourseSectionService;
use App\Services\LiveClass\ZoomLiveService;
use App\Models\Course\Course;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class PlayerController extends Controller
{
private function findCourseContentByIdAndType(Course $course, string $contentId, string $contentType): ?array
{
if (!in_array($contentType, ['lesson', 'quiz'], true)) {
return null;
}
foreach ($course->sections as $section) {
if ($contentType === 'lesson') {
foreach ($section->section_lessons as $lesson) {
if ((string) $lesson->id === (string) $contentId) {
return ['type' => 'lesson', 'id' => $lesson->id];
}
}
} else {
foreach ($section->section_quizzes as $quiz) {
if ((string) $quiz->id === (string) $contentId) {
return ['type' => 'quiz', 'id' => $quiz->id];
}
}
}
}
return null;
}
private function findCourseContentById(Course $course, string $contentId): ?array
{
foreach ($course->sections as $section) {
foreach ($section->section_lessons as $lesson) {
if ((string) $lesson->id === (string) $contentId) {
return ['type' => 'lesson', 'id' => $lesson->id];
}
}
foreach ($section->section_quizzes as $quiz) {
if ((string) $quiz->id === (string) $contentId) {
return ['type' => 'quiz', 'id' => $quiz->id];
}
}
}
return null;
}
private function findFirstCourseContent(Course $course): ?array
{
foreach ($course->sections as $section) {
$lesson = $section->section_lessons->first();
if ($lesson) {
return ['type' => 'lesson', 'id' => $lesson->id];
}
$quiz = $section->section_quizzes->first();
if ($quiz) {
return ['type' => 'quiz', 'id' => $quiz->id];
}
}
return null;
}
public function __construct(
protected CourseService $courseService,
protected CoursePlayerService $coursePlay,
@ -36,6 +99,10 @@ class PlayerController extends Controller
$user = Auth::user();
$watchHistory = $this->sectionService->initWatchHistory($request->course_id, 'lesson', $user->id);
if (! $watchHistory) {
return back()->with('error', 'Dieser Kurs hat noch keine Lektionen oder Quizze.');
}
return redirect()->route('course.player', [
'type' => $watchHistory->current_watching_type,
'watch_history' => $watchHistory->id,
@ -48,29 +115,64 @@ class PlayerController extends Controller
try {
$user = Auth::user();
$section_id = $watch_history->current_section_id;
$watching_id = $lesson_id ?? $watch_history->current_watching_id;
$watching_type = $type; // Use the route parameter, not old watch history
$watching_id = $lesson_id ?: $watch_history->current_watching_id;
$watching_type = in_array($type, ['lesson', 'quiz'], true) ? $type : ($watch_history->current_watching_type ?? 'lesson');
$course = $this->courseService->getUserCourseById($watch_history->course_id, $user);
$watching = $this->coursePlay->getWatchingLesson($lesson_id, $type);
if (! $course) {
return redirect()
->route('category.courses', ['category' => 'all'])
->with('error', 'Der Kurs konnte nicht gefunden werden.');
}
// Fix wrong `type`/`lesson_id` combinations by resolving the content inside the course.
$resolved = $this->findCourseContentByIdAndType($course, (string) $watching_id, $watching_type);
if (! $resolved) {
$resolved = $this->findCourseContentById($course, (string) $watching_id);
}
if (! $resolved) {
$resolved = $this->findCourseContentByIdAndType($course, (string) $watch_history->current_watching_id, (string) $watch_history->current_watching_type);
}
if (! $resolved) {
$resolved = $this->findCourseContentById($course, (string) $watch_history->current_watching_id);
}
if (! $resolved) {
$resolved = $this->findFirstCourseContent($course);
}
if (! $resolved) {
return redirect()
->route('course.details', ['slug' => $course->slug, 'id' => $course->id])
->with('error', 'Dieser Kurs hat noch keine Lektionen oder Quizze.');
}
$watching_id = (string) $resolved['id'];
$watching_type = $resolved['type'];
// Canonicalize URL if it doesnt match the resolved content.
if ($type !== $watching_type || (string) $lesson_id !== (string) $watching_id) {
return redirect()->route('course.player', [
'type' => $watching_type,
'watch_history' => $watch_history->id,
'lesson_id' => $watching_id,
]);
}
$watching = $this->coursePlay->getWatchingLesson($watching_id, $watching_type, (string) $course->id);
$reviews = $this->reviewService->getReviews(['course_id' => $course->id, ...$request->all()], true);
$userReview = $this->reviewService->userReview($course->id, $user->id);
$totalReviews = $this->reviewService->totalReviews($course->id);
$zoomConfig = $this->zoomLiveService->zoomConfig;
$section = null;
$totalContent = 0;
foreach ($course->sections as $courseSection) {
$totalContent += count($courseSection->section_lessons) + count($courseSection->section_quizzes);
if ($courseSection->id == $section_id) {
$section = $courseSection;
}
}
$watchHistory = $this->coursePlay->watchHistory($course, $watching_id, $watching_type, $user->id);
$section = $course->sections->firstWhere('id', $watchHistory->current_section_id);
// $submissions = null;
// if ($assignment) {
@ -78,7 +180,7 @@ class PlayerController extends Controller
// }
return Inertia::render('course-player/index', [
'type' => $type,
'type' => $watching_type,
'course' => $course,
'section' => $section,
'reviews' => $reviews,
@ -89,6 +191,56 @@ class PlayerController extends Controller
'totalReviews' => $totalReviews,
'zoomConfig' => $zoomConfig,
]);
} catch (ModelNotFoundException $th) {
$user = Auth::user();
$course = $this->courseService->getUserCourseById($watch_history->course_id, $user);
if ($course) {
$watchHistoryForUser = WatchHistory::where('course_id', $course->id)
->where('user_id', $user->id)
->first();
$watchHistoryId = $watchHistoryForUser?->id ?? $watch_history->id;
$requestedId = $lesson_id;
$requestedType = $watching_type ?? $type;
// If the ID exists in the course but the type is wrong, redirect with the correct type.
$resolved = $this->findCourseContentById($course, $requestedId);
if ($resolved && $resolved['type'] !== $requestedType) {
return redirect()->route('course.player', [
'type' => $resolved['type'],
'watch_history' => $watchHistoryId,
'lesson_id' => $resolved['id'],
]);
}
// If the watch history points to a valid item, jump there.
$resolved = $this->findCourseContentById($course, (string) $watch_history->current_watching_id);
if ($resolved && ((string) $resolved['id'] !== (string) $requestedId || $resolved['type'] !== $requestedType)) {
return redirect()->route('course.player', [
'type' => $resolved['type'],
'watch_history' => $watchHistoryId,
'lesson_id' => $resolved['id'],
]);
}
// Last resort: send the user to the first available content in the course.
$first = $this->findFirstCourseContent($course);
if ($first && ((string) $first['id'] !== (string) $requestedId || $first['type'] !== $requestedType)) {
return redirect()->route('course.player', [
'type' => $first['type'],
'watch_history' => $watchHistoryId,
'lesson_id' => $first['id'],
]);
}
return redirect()
->route('course.details', ['slug' => $course->slug, 'id' => $course->id])
->with('error', 'Dieser Kurs hat noch keine Lektionen oder Quizze.');
}
return redirect()->route('category.courses', ['category' => 'all'])->with('error', 'Der angeforderte Inhalt konnte nicht gefunden werden.');
} catch (\Throwable $th) {
return redirect()->route('category.courses', ['category' => 'all'])->with('error', $th->getMessage());
}
@ -123,7 +275,7 @@ class PlayerController extends Controller
$watch_history->completion_date = now();
$watch_history->save();
return back()->with('success', 'Course completed successfully');
return back()->with('success', 'Kurs erfolgreich abgeschlossen.');
}
/**

View File

@ -37,7 +37,7 @@ class NotificationController extends Controller
{
$this->notificationService->markAllAsRead();
return redirect()->back()->with('success', 'All notifications marked as read');
return redirect()->back()->with('success', 'Alle Benachrichtigungen wurden als gelesen markiert');
}
/**

View File

@ -30,6 +30,19 @@ class StudentController extends Controller
*/
public function index(Request $request, string $tab)
{
$system = app('system_settings');
$fields = $system?->fields ?? [];
$showExams = $fields['show_student_exams'] ?? true;
$showWishlist = $fields['show_student_wishlist'] ?? true;
if ($tab === 'exams' && !$showExams) {
abort(404);
}
if ($tab === 'wishlist' && !$showWishlist) {
abort(404);
}
if ($tab !== 'courses' && !$request->user()->hasVerifiedEmail()) {
return redirect()
->route('student.index', ['tab' => 'courses'])
@ -47,6 +60,15 @@ class StudentController extends Controller
public function show_course(int $id, string $tab)
{
$system = app('system_settings');
$fields = $system?->fields ?? [];
$showCourseCertificate = $fields['show_course_certificate'] ?? true;
$showCourseMarksheet = $fields['show_course_marksheet'] ?? $showCourseCertificate;
if ($tab === 'certificate' && !$showCourseCertificate && !$showCourseMarksheet) {
return redirect()->route('student.course.show', ['id' => $id, 'tab' => 'modules']);
}
$user = Auth::user();
$course = $this->studentService->getEnrolledCourse($id, $user);
$props = $this->studentService->getEnrolledCourseOverview($id, $tab, $user);
@ -66,6 +88,13 @@ class StudentController extends Controller
public function show_exam(Request $request, int $id, string $tab)
{
$system = app('system_settings');
$fields = $system?->fields ?? [];
if (($fields['show_student_exams'] ?? true) === false) {
abort(404);
}
$user = Auth::user();
$exam = $this->examEnrollment->getEnrolledExam($id, $user);
$attempts = $this->examAttempt->getExamAttempts(['exam_id' => $id, 'user_id' => $user->id]);

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreUserRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Models\User;
use App\Services\UserService;
@ -28,6 +29,16 @@ class UsersController extends Controller
return Inertia::render('dashboard/users/index', compact('users'));
}
/**
* Store a newly created user and send an invite.
*/
public function store(StoreUserRequest $request): RedirectResponse
{
$this->userService->inviteUser($request->validated());
return redirect()->back()->with('success', 'User invited successfully. A verification email has been sent.');
}
/**
* Update the user's account.
*/

View File

@ -26,11 +26,19 @@ class CheckEnroll
}
$watchHistory = $request->route('watch_history');
if (!WatchHistory::find($watchHistory)) {
if (!($watchHistory instanceof WatchHistory)) {
$watchHistory = WatchHistory::find($watchHistory);
}
if (!$watchHistory) {
return back()->with('error', 'Invalid watch history');
}
$course = Course::find($watchHistory->course_id);
if (!$course) {
return back()->with('error', 'Invalid course');
}
if ($user->role == 'instructor' && $user->instructor_id == $course->instructor_id) {
return $next($request);

View File

@ -26,6 +26,6 @@ class CheckRole
}
// If user doesn't have the required role
return redirect()->back()->with('error', 'You do not have permission to access this page.');
return redirect()->back()->with('error', 'Du hast keine Berechtigung, auf diese Seite zuzugreifen.');
}
}

View File

@ -65,9 +65,11 @@ class HandleInertiaRequests extends Middleware
if (Schema::hasTable('languages')) {
$langs = Language::where('is_active', true)->orderBy('is_default', 'desc')->get();
$default = $langs->where('is_default', true)->first()->code;
$defaultLang = $langs->firstWhere('is_default', true);
$default = $defaultLang?->code ?? config('app.locale', 'en');
config(['app.locale' => $default]);
$locale = Cookie::get('locale', $default);
$requestedLocale = Cookie::get('locale', $default);
$locale = $langs->contains('code', $requestedLocale) ? $requestedLocale : $default;
App::setLocale($locale);
$this->languageService->setLanguageProperties($locale);

View File

@ -63,7 +63,7 @@ class StoreQuizRequest extends FormRequest
}
}
],
'retake' => 'required|numeric|min:1',
'retake' => 'required|numeric|min:0',
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:users,email'],
'status' => ['required', 'integer', 'in:0,1'],
];
}
}

View File

@ -62,7 +62,7 @@ class UpdateQuizRequest extends FormRequest
}
}
],
'retake' => 'required|numeric|min:1',
'retake' => 'required|numeric|min:0',
];
}
}

View File

@ -13,13 +13,7 @@ class VerifyEmailNotification extends Notification
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct()
{
//
}
public function __construct(protected bool $invite = false) {}
/**
* Get the notification's delivery channels.
@ -39,13 +33,19 @@ class VerifyEmailNotification extends Notification
*/
protected function verificationUrl($notifiable)
{
$params = [
'id' => $notifiable->getKey(),
'hash' => sha1($notifiable->getEmailForVerification()),
];
if ($this->invite) {
$params['invite'] = 1;
}
return URL::temporarySignedRoute(
'verification.verify',
now()->addMinutes(config('auth.verification.expire', 5)),
[
'id' => $notifiable->getKey(),
'hash' => sha1($notifiable->getEmailForVerification()),
]
$params
);
}
@ -57,7 +57,7 @@ class VerifyEmailNotification extends Notification
$verificationUrl = $this->verificationUrl($notifiable);
return (new MailMessage)
->subject('Verify Email Address')
->subject('E-Mail-Adresse bestätigen')
->view('mail.email-verification', [
'user' => $notifiable,
'url' => $verificationUrl,

View File

@ -66,22 +66,9 @@ class AppServiceProvider extends ServiceProvider
return env('FRONTEND_URL') . '/reset-password?token=' . $token . '&email=' . $user->email;
});
// Trust proxies when running behind a reverse proxy (e.g., Docker, nginx)
// This allows Laravel to correctly detect HTTPS when behind a proxy
if (config('app.env') !== 'local' || request()->hasHeader('X-Forwarded-Proto')) {
request()->setTrustedProxies(
['*'],
\Illuminate\Http\Request::HEADER_X_FORWARDED_FOR |
\Illuminate\Http\Request::HEADER_X_FORWARDED_HOST |
\Illuminate\Http\Request::HEADER_X_FORWARDED_PORT |
\Illuminate\Http\Request::HEADER_X_FORWARDED_PROTO |
\Illuminate\Http\Request::HEADER_X_FORWARDED_PREFIX
);
}
// Force HTTPS scheme for URLs when accessed via HTTPS
// This ensures assets load with the correct protocol
if (request()->header('X-Forwarded-Proto') === 'https' || request()->secure()) {
// Force HTTPS URLs in non-local envs to avoid mixed-content issues when TLS is terminated
// in front of the app container (e.g. Dokploy/Traefik).
if (!$this->app->runningInConsole() && !$this->app->environment('local')) {
URL::forceScheme('https');
}
}

View File

@ -24,8 +24,12 @@ class CourseCouponService
->get();
}
public function getCoupon(string $code): ?CourseCoupon
public function getCoupon(?string $code): ?CourseCoupon
{
if (!$code) {
return null;
}
return CourseCoupon::where('code', $code)->first();
}

View File

@ -12,12 +12,12 @@ use Illuminate\Support\Facades\Auth;
class CoursePlayerService
{
function getWatchingLesson(string $lesson_id, string $watching_type): SectionLesson | SectionQuiz
function getWatchingLesson(string $lesson_id, string $watching_type, ?string $course_id = null): SectionLesson | SectionQuiz
{
$user = Auth::user();
return $watching_type === 'lesson' ?
SectionLesson::with([
if ($watching_type === 'lesson') {
$query = SectionLesson::with([
'resources',
'forums' => function ($query) {
$query->with([
@ -27,13 +27,27 @@ class CoursePlayerService
},
]);
},
])->find($lesson_id) :
SectionQuiz::with([
]);
if ($course_id) {
$query->where('course_id', $course_id);
}
return $query->findOrFail($lesson_id);
}
$query = SectionQuiz::with([
'quiz_questions',
'quiz_submissions' => function ($query) use ($user) {
$query->where('user_id', $user->id);
}
])->find($lesson_id);
]);
if ($course_id) {
$query->where('course_id', $course_id);
}
return $query->findOrFail($lesson_id);
}
function getWatchHistory(string $course_id, ?string $user_id): ?WatchHistory

View File

@ -289,21 +289,50 @@ class CourseSectionService extends MediaService
public function initWatchHistory(string $course_id, string $watching_type, string $user_id): ?WatchHistory
{
$lesson = SectionLesson::query()->where('course_id', $course_id);
$history = WatchHistory::where('course_id', $course_id)
->where('user_id', $user_id)
->first();
if ($lesson->count() >= 0 && !$history) {
$lesson = $lesson->orderBy('sort', 'asc')->first();
$coursePlay = new CoursePlayerService();
$course = Course::where('id', $course_id)->with('sections')->first();
return $coursePlay->watchHistory($course, $lesson->id, $watching_type, $user_id);
if ($history) {
return $history;
}
return $history;
$course = Course::where('id', $course_id)->with([
'sections.section_lessons',
'sections.section_quizzes',
])->first();
if (! $course) {
return null;
}
$preferredTypes = $watching_type === 'quiz' ? ['quiz', 'lesson'] : ['lesson', 'quiz'];
$firstItem = null;
foreach ($preferredTypes as $preferredType) {
foreach ($course->sections as $section) {
if ($preferredType === 'lesson') {
$lesson = $section->section_lessons->first();
if ($lesson) {
$firstItem = ['type' => 'lesson', 'id' => $lesson->id];
break 2;
}
} else {
$quiz = $section->section_quizzes->first();
if ($quiz) {
$firstItem = ['type' => 'quiz', 'id' => $quiz->id];
break 2;
}
}
}
}
if (! $firstItem) {
return null;
}
$coursePlay = new CoursePlayerService();
return $coursePlay->watchHistory($course, (string) $firstItem['id'], $firstItem['type'], $user_id);
}
/**

View File

@ -50,13 +50,15 @@ class SectionQuizService extends CourseSectionService
->where('section_quiz_id', $quiz->id)
->first();
$hasAttemptLimit = $quiz->retake > 0;
// Get or create quiz submission
if ($submission) {
if ($submission->attempts >= $quiz->retake) {
if ($hasAttemptLimit && $submission->attempts >= $quiz->retake) {
return false;
} else {
$submission->increment('attempts');
}
$submission->increment('attempts');
} else {
$submission = QuizSubmission::create([
'section_quiz_id' => $quiz->id,

View File

@ -110,7 +110,7 @@ class StudentService extends MediaService
->first();
if (!$enrollment) {
throw new \Exception('You are not enrolled in this course');
abort(403, 'You are not enrolled in this course');
}
return Course::with(['instructor:id,user_id', 'instructor.user:id,name,photo'])->find($id);
@ -185,15 +185,20 @@ class StudentService extends MediaService
function getEnrolledCourseOverview(string $course_id, string $tab, User $user): array
{
$system = app('system_settings');
$fields = $system?->fields ?? [];
$showCourseCertificate = $fields['show_course_certificate'] ?? true;
$showCourseMarksheet = $fields['show_course_marksheet'] ?? $showCourseCertificate;
return [
'modules' => $tab === 'modules' ? $this->getCourseModules($course_id) : null,
'live_classes' => $tab === 'live_classes' ? $this->getCourseLiveClasses($course_id) : null,
'assignments' => $tab === 'assignments' ? $this->getCourseAssignments($course_id, $user) : null,
'quizzes' => $tab === 'quizzes' ? $this->getCourseSectionQuizzes($course_id, $user) : null,
'resources' => $tab === 'resources' ? $this->getCourseLessonResources($course_id) : null,
'certificateTemplate' => $tab === 'certificate' ? $this->certificate->getActiveCertificateTemplate('course') : null,
'marksheetTemplate' => $tab === 'certificate' ? $this->certificate->getActiveMarksheetTemplate('course') : null,
'studentMarks' => $tab === 'certificate' ? $this->calculateStudentMarks($course_id, $user->id) : null,
'certificateTemplate' => $tab === 'certificate' && $showCourseCertificate ? $this->certificate->getActiveCertificateTemplate('course') : null,
'marksheetTemplate' => $tab === 'certificate' && $showCourseMarksheet ? $this->certificate->getActiveMarksheetTemplate('course') : null,
'studentMarks' => $tab === 'certificate' && $showCourseMarksheet ? $this->calculateStudentMarks($course_id, $user->id) : null,
];
}

View File

@ -2,10 +2,14 @@
namespace App\Services;
use App\Enums\UserType;
use App\Models\User;
use App\Notifications\VerifyEmailNotification;
use Illuminate\Support\Facades\DB;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class UserService
{
@ -34,4 +38,23 @@ class UserService
User::find($id)->update($data);
}, 5);
}
public function inviteUser(array $data): User
{
return DB::transaction(function () use ($data) {
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'role' => UserType::STUDENT->value,
'status' => $data['status'] ?? 1,
'password' => Hash::make(Str::random(32)),
]);
DB::afterCommit(function () use ($user) {
$user->notify(new VerifyEmailNotification(true));
});
return $user;
}, 5);
}
}

View File

@ -44,6 +44,10 @@ return Application::configure(basePath: dirname(__DIR__))
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
]);
// Trust reverse proxy headers (X-Forwarded-Proto, etc.) so Laravel generates HTTPS URLs
// correctly when TLS is terminated in front of the container (e.g. Dokploy/Traefik).
$middleware->trustProxies(at: '*');
$middleware->encryptCookies(except: ['appearance']);
$middleware->web(append: [

View File

@ -1,13 +1,10 @@
version: "3.8"
services:
app:
build:
context: .
dockerfile: Dockerfile
image: websites-lms:latest
ports:
- "8080:80"
environment:
APP_ENV: production
APP_DEBUG: "false"
image: git.cloudarix.de/ahmido/lms:${IMAGE_TAG}
restart: unless-stopped
volumes:
- websites_lms_storage:/var/www/html/storage
- /etc/dokploy/compose/websites-lms-txuykq/code/.env:/var/www/html/.env
volumes:
websites_lms_storage:

View File

@ -1,4 +1,4 @@
APP_NAME="Mentor LMS"
APP_NAME="Mentor-LMS"
APP_ENV=production
APP_KEY=
APP_DEBUG=false

33
lang/de/auth.php Normal file
View File

@ -0,0 +1,33 @@
<?php
return [
'failed' => 'Diese Zugangsdaten stimmen nicht mit unseren Aufzeichnungen überein.',
'password' => 'Das angegebene Passwort ist falsch.',
'throttle' => 'Zu viele Anmeldeversuche. Bitte versuche es in :seconds Sekunden erneut.',
'password_updated' => 'Dein Passwort wurde aktualisiert.',
'verification_link_sent' => 'Ein neuer Bestätigungslink wurde an deine E-Mail-Adresse gesendet.',
'password_reset_sent' => 'Wir haben dir den Link zum Zurücksetzen des Passworts per E-Mail gesendet.',
'google_auth_settings' => 'Google-Auth-Einstellungen',
'google_auth_description' => 'Google-Auth-Beschreibung',
'login_title' => 'Melde dich bei deinem Konto an',
'login_description' => 'Gib unten deine E-Mail-Adresse und dein Passwort ein, um dich anzumelden.',
'remember_me' => 'Angemeldet bleiben',
'forgot_password' => 'Passwort vergessen',
'continue_with' => 'Oder fortfahren mit',
'no_account' => 'Du hast noch kein Konto?',
'google_auth' => 'Google Auth',
'register_title' => 'Konto erstellen',
'register_description' => 'Gib unten deine Daten ein, um dein Konto zu erstellen.',
'have_account' => 'Du hast schon ein Konto?',
'forgot_description' => 'Gib deine E-Mail-Adresse ein, um einen Link zum Zurücksetzen des Passworts zu erhalten.',
'return_to_login' => 'Oder zurück zu',
'reset_title' => 'Passwort zurücksetzen',
'reset_description' => 'Bitte gib unten dein neues Passwort ein.',
'confirm_title' => 'Passwort bestätigen',
'confirm_description' => 'Dies ist ein geschützter Bereich der Anwendung. Bitte bestätige dein Passwort, bevor du fortfährst.',
'change_email' => 'E-Mail-Adresse ändern',
'verify_title' => 'E-Mail-Adresse bestätigen',
'verify_description' => 'Bitte bestätige deine E-Mail-Adresse, indem du auf den Link klickst, den wir dir gerade per E-Mail gesendet haben.',
'verification_sent' => 'Ein neuer Bestätigungslink wurde an die E-Mail-Adresse gesendet, die du bei der Registrierung angegeben hast.',
];

219
lang/de/button.php Normal file
View File

@ -0,0 +1,219 @@
<?php
return [
'add' => 'Hinzufügen',
'save' => 'Speichern',
'cancel' => 'Abbrechen',
'close' => 'Schließen',
'submit' => 'Absenden',
'edit' => 'Bearbeiten',
'delete' => 'Löschen',
'create' => 'Erstellen',
'update' => 'Aktualisieren',
'remove' => 'Entfernen',
'previous' => 'Zurück',
'apply' => 'Anwenden',
'view_all' => 'Alle anzeigen',
'reply' => 'Antworten',
'reapply' => 'Erneut anwenden',
'light' => 'Hell',
'dark' => 'Dunkel',
'collaborative' => 'Kollaborativ',
'administrative' => 'Administrativ',
'type' => 'Typ',
'form' => 'Formular',
'true' => 'Wahr',
'false' => 'Falsch',
'free' => 'Kostenlos',
'list' => 'Liste',
'search' => 'Suchen',
'filter' => 'Filter',
'sort' => 'Sortieren',
'refresh' => 'Aktualisieren',
'upload' => 'Hochladen',
'download' => 'Herunterladen',
'send' => 'Senden',
'add_item' => 'Element hinzufügen',
'reorder' => 'Neu anordnen',
'active' => 'Aktiv',
'inactive' => 'Inaktiv',
'deactivate' => 'Deaktivieren',
'lessons' => 'Lektionen',
'live_classes' => 'Live-Kurse',
'join_class' => 'Kurs beitreten',
'edit_live_class' => 'Live-Kurs bearbeiten',
'delete_class' => 'Kurs löschen',
'class_note' => 'Kursnotiz',
'add_language' => 'Sprache hinzufügen',
'save_changes' => 'Änderungen speichern',
'schedule_class' => 'Kurs planen',
'add_newsletter' => 'Newsletter hinzufügen',
'update_newsletter' => 'Newsletter aktualisieren',
'delete_backup' => 'Sicherung löschen',
'refresh_server' => 'Server aktualisieren',
'backup_now' => 'Jetzt sichern',
'update_application' => 'Anwendung aktualisieren',
'set_default' => 'Als Standard setzen',
'play_course' => 'Kurs starten',
'course_player' => 'Kurs-Player',
'enroll_now' => 'Jetzt einschreiben',
'buy_now' => 'Jetzt kaufen',
'add_to_cart' => 'In den Warenkorb',
'add_to_wishlist' => 'Zur Wunschliste hinzufügen',
'remove_from_wishlist' => 'Von der Wunschliste entfernen',
'learn_more' => 'Mehr erfahren',
'continue' => 'Weiter',
'course_details' => 'Kursdetails',
'continue_to_payment' => 'Weiter zur Zahlung',
'post_comment' => 'Kommentar posten',
'posting' => 'Wird gepostet...',
'like' => 'Gefällt mir',
'dislike' => 'Gefällt mir nicht',
'submit_review' => 'Bewertung absenden',
'next_step' => 'Nächster Schritt',
'previous_step' => 'Vorheriger Schritt',
'generate_certificate' => 'Zertifikat erstellen',
'generating_certificate' => 'Zertifikat wird erstellt...',
'download_as' => 'Herunterladen als',
'mark_all_as_read' => 'Alle als gelesen markieren',
'view_all_notifications' => 'Alle Benachrichtigungen anzeigen',
'subscribe' => 'Abonnieren',
'become_an_instructor' => 'Dozent werden',
'delete_account' => 'Konto löschen',
'are_you_sure_to_delete' => 'Bist du sicher, dass du löschen möchtest?',
'add_section' => 'Abschnitt hinzufügen',
'sort_section' => 'Abschnitte sortieren',
'add_quiz' => 'Quiz hinzufügen',
'add_lesson' => 'Lektion hinzufügen',
'sort_lessons' => 'Lektionen sortieren',
'sort_categories' => 'Kategorien sortieren',
'edit_section' => 'Abschnitt bearbeiten',
'delete_section' => 'Abschnitt löschen',
'quiz_questions' => 'Quizfragen',
'add_question' => 'Frage hinzufügen',
'save_lesson' => 'Lektion speichern',
'pick_a_date' => 'Datum auswählen',
'toggle_theme' => 'Theme wechseln',
'course_preview' => 'Kursvorschau',
'submit_for_approval' => 'Zur Freigabe einreichen',
'approval_status' => 'Freigabestatus',
'login' => 'Anmelden',
'logout' => 'Abmelden',
'log_out' => 'Abmelden',
'log_in' => 'Anmelden',
'sign_up' => 'Registrieren',
'create_account' => 'Konto erstellen',
'continue_with_google' => 'Weiter mit Google',
'forgot_password' => 'Passwort vergessen?',
'email_password_reset_link' => 'Link zum Zurücksetzen des Passworts per E-Mail senden',
'resend_verification_email' => 'Bestätigungs-E-Mail erneut senden',
'confirm_password' => 'Passwort bestätigen',
'reset_password' => 'Passwort zurücksetzen',
'change_email' => 'E-Mail ändern',
'change_password' => 'Passwort ändern',
'forget_password' => 'Passwort vergessen',
'get_email_change_link' => 'Link zum Ändern der E-Mail erhalten',
'get_password_reset_link' => 'Link zum Zurücksetzen des Passworts erhalten',
'upload_file' => 'Datei hochladen',
'start_upload' => 'Upload starten',
'cancel_upload' => 'Upload abbrechen',
'select_different_file' => 'Andere Datei auswählen',
'initializing_upload' => 'Upload wird initialisiert...',
'uploading_file_chunks' => 'Dateiteile werden hochgeladen...',
'finalizing_upload' => 'Upload wird abgeschlossen...',
'completed_upload' => 'Upload abgeschlossen',
'upload_completed_successfully' => 'Upload erfolgreich abgeschlossen',
'uploading' => 'Wird hochgeladen...',
'saving' => 'Wird gespeichert...',
'first' => 'Erste',
'last' => 'Letzte',
'prev' => 'Zurück',
'next' => 'Weiter',
'back' => 'Zurück',
'show_full' => 'Alles anzeigen',
'show_less' => 'Weniger anzeigen',
'dashboard' => 'Armaturenbrett',
'main_menu' => 'Hauptmenü',
'categories' => 'Kategorien',
'courses' => 'Kurse',
'exams' => 'Prüfungen',
'enrollments' => 'Einschreibungen',
'instructors' => 'Dozenten',
'payout_report' => 'Auszahlungsbericht',
'payment_report' => 'Zahlungsbericht',
'payouts' => 'Auszahlungen',
'job_circulars' => 'Stellenausschreibungen',
'blogs' => 'Blogs',
'newsletters' => 'Newsletter',
'all_users' => 'Alle Nutzer',
'certificates' => 'Zertifikate',
'settings' => 'Einstellungen',
'my_courses' => 'Meine Kurse',
'wishlist' => 'Wunschliste',
'profile' => 'Profil',
'live_class' => 'Live-Kurs',
'payment_methods' => 'Zahlungsmethoden',
'profile_update' => 'Profil aktualisieren',
'manage_courses' => 'Kurse verwalten',
'create_course' => 'Kurs erstellen',
'course_coupons' => 'Kursgutscheine',
'manage_exams' => 'Prüfungen verwalten',
'create_exam' => 'Prüfung erstellen',
'exam_coupons' => 'Prüfungsgutscheine',
'course_enrollments' => 'Kurseinschreibungen',
'exam_enrollments' => 'Prüfungseinschreibungen',
'online_payments' => 'Online-Zahlungen',
'offline_payments' => 'Offline-Zahlungen',
'manage_enrollments' => 'Einschreibungen verwalten',
'add_new_enrollment' => 'Neue Einschreibung hinzufügen',
'add_new_instructor' => 'Neuen Dozenten hinzufügen',
'manage_instructors' => 'Dozenten verwalten',
'create_instructor' => 'Dozenten erstellen',
'applications' => 'Anwendungen',
'payout_request' => 'Auszahlungsanfrage',
'payout_history' => 'Auszahlungsverlauf',
'withdraw' => 'Auszahlen',
'all_jobs' => 'Alle Jobs',
'create_job' => 'Job erstellen',
'edit_job' => 'Job bearbeiten',
'create_blog' => 'Blog erstellen',
'manage_blog' => 'Blog verwalten',
'account' => 'Konto',
'system' => 'System',
'pages' => 'Seiten',
'storage' => 'Speicher',
'smtp' => 'SMTP',
'auth0' => 'Auth0',
'maintenance' => 'Wartung',
'marksheet' => 'Notenblatt',
'become_instructor' => 'Dozent werden',
'overview' => 'Übersicht',
'curriculum' => 'Lehrplan',
'details' => 'Details',
'instructor' => 'Dozent',
'reviews' => 'Bewertungen',
'summery' => 'Zusammenfassung',
'certificate' => 'Zertifikat',
'forum' => 'Forum',
'review' => 'Bewertung',
'basic' => 'Grundlegend',
'pricing' => 'Preise',
'info' => 'Info',
'media' => 'Medien',
'seo' => 'SEO',
'quiz' => 'Quiz',
'lesson' => 'Lektion',
'social' => 'Soziales',
'payment' => 'Zahlung',
'copyright' => 'Copyright',
'social_media' => 'Soziale Medien',
'url_items' => 'URL-Elemente',
'dropdowns' => 'Dropdowns',
'actions' => 'Aktionen',
'website' => 'Website',
'navbar' => 'Navbar',
'footer' => 'Footer',
'style' => 'Stil',
'edit_navbar' => 'Navbar bearbeiten',
'edit_footer' => 'Footer bearbeiten',
];

107
lang/de/common.php Normal file
View File

@ -0,0 +1,107 @@
<?php
return [
'title' => 'Titel',
'description' => 'Beschreibung',
'name' => 'Name',
'email' => 'E-Mail',
'password' => 'Passwort',
'type' => 'Typ',
'category' => 'Kategorie',
'status' => 'Status',
'location' => 'Standort',
'image' => 'Bild',
'url' => 'URL',
'categories' => 'Kategorien',
'students' => 'Teilnehmer',
'courses' => 'Kurse',
'comments' => 'Kommentare',
'comment' => 'Kommentar',
'price' => 'Preis',
'rating' => 'Bewertung',
'instructor' => 'Dozent',
'role' => 'Rolle',
'action' => 'Aktion',
'actions' => 'Aktionen',
'level' => 'Level',
'active' => 'Aktiv',
'inactive' => 'Inaktiv',
'published' => 'Veröffentlicht',
'draft' => 'Entwurf',
'expired' => 'Abgelaufen',
'pending' => 'Ausstehend',
'enabled' => 'Aktiviert',
'disabled' => 'Deaktiviert',
'language' => 'Sprache',
'default' => 'Standard',
'closed' => 'Geschlossen',
'paused' => 'Pausiert',
'on' => 'An',
'off' => 'Aus',
'free' => 'Kostenlos',
'completed' => 'Abgeschlossen',
'search' => 'Suchen',
'filter' => 'Filter',
'create' => 'Erstellen',
'update' => 'Aktualisieren',
'edit' => 'Bearbeiten',
'save' => 'Speichern',
'delete' => 'Löschen',
'cancel' => 'Abbrechen',
'submit' => 'Absenden',
'apply' => 'Anwenden',
'view' => 'Ansehen',
'preview' => 'Vorschau',
'download' => 'Herunterladen',
'close' => 'Schließen',
'confirm' => 'Bestätigen',
'yes' => 'Ja',
'no' => 'Nein',
'ok' => 'OK',
'back' => 'Zurück',
'continue' => 'Weiter',
'skip' => 'Überspringen',
'retry' => 'Erneut versuchen',
'refresh' => 'Aktualisieren',
'reload' => 'Neu laden',
'change_email' => 'E-Mail ändern',
'change_password' => 'Passwort ändern',
'forget_password' => 'Passwort vergessen',
'loading' => 'Wird geladen...',
'processing' => 'Wird verarbeitet...',
'saving' => 'Wird gespeichert...',
'updating' => 'Wird aktualisiert...',
'deleting' => 'Wird gelöscht...',
'uploading' => 'Wird hochgeladen...',
'searching' => 'Wird gesucht...',
'no_results_found' => 'Keine Ergebnisse gefunden',
'today' => 'Heute',
'yesterday' => 'Gestern',
'ago' => 'vor',
'general_settings' => 'Allgemeine Einstellungen',
'email_settings' => 'E-Mail-Einstellungen',
'payment_settings' => 'Zahlungseinstellungen',
'settings' => 'Einstellungen',
'file_too_large' => 'Die Datei ist zu groß. Bitte wähle eine kleinere Datei aus.',
'invalid_file' => 'Ungültiges Dateiformat. Bitte lade eine gültige .zip-Datei hoch.',
'required_field' => 'Dieses Feld ist erforderlich.',
'home' => 'Startseite',
'about' => 'Über uns',
'contact' => 'Kontakt',
'help' => 'Hilfe',
'support' => 'Support',
'dashboard' => 'Dashboard',
'page' => 'Seite',
'of' => 'von',
'first' => '<<Erste',
'previous' => 'Zurück',
'next' => 'Weiter',
'last' => 'Letzte>>',
'go_to_page' => 'Gehe zu Seite',
'video' => 'Video',
'audio' => 'Audio',
'document' => 'Dokument',
'file' => 'Datei',
'reviews' => 'Bewertungen',
'select_the_approval_status' => 'Freigabestatus auswählen',
];

307
lang/de/dashboard.php Normal file
View File

@ -0,0 +1,307 @@
<?php
return [
'user_management' => 'Benutzerverwaltung',
'course_management' => 'Kursverwaltung',
'content_management' => 'Inhaltsverwaltung',
'financial_management' => 'Finanzverwaltung',
'refund_management' => 'Rückerstattungsverwaltung',
'faq_management' => 'FAQ-Verwaltung',
'security_settings' => 'Sicherheitseinstellungen',
'backup_settings' => 'Sicherungseinstellungen',
'tax_settings' => 'Steuereinstellungen',
'webhook_settings' => 'Webhook-Einstellungen',
'analytics' => 'Analysen',
'reports' => 'Berichte',
'visitor_analytics' => 'Besucheranalysen',
'course_analytics' => 'Kursanalysen',
'enrollment_statistics' => 'Einschreibestatistik',
'revenue_statistics' => 'Umsatzstatistik',
'course_statistics' => 'Kursstatistik',
'sales_report' => 'Verkaufsbericht',
'user_activity_report' => 'Nutzeraktivitätsbericht',
'course_performance_report' => 'Kursleistungsbericht',
'financial_report' => 'Finanzbericht',
'export_report' => 'Bericht exportieren',
'basic_information' => 'Grundinformationen',
'blog_information' => 'Blog-Informationen',
'user_information' => 'Benutzerinformationen',
'media_details' => 'Mediendetails',
'compensation_details' => 'Vergütung & Details',
'total_blogs' => 'Blogs gesamt',
'total_categories' => 'Kategorien gesamt',
'total_jobs' => 'Jobs gesamt',
'total_users' => 'Benutzer gesamt',
'total_earnings' => 'Einnahmen gesamt',
'total_withdrawals' => 'Auszahlungen gesamt',
'select_user_type' => 'Benutzertyp auswählen',
'select_withdrawal_method' => 'Auszahlungsmethode auswählen',
'select_recipients' => 'Empfänger auswählen',
'select_media' => 'Medien auswählen',
'no_results' => 'Keine Ergebnisse.',
'no_results_found' => 'Keine Ergebnisse gefunden',
'are_you_absolutely_sure' => 'Bist du dir absolut sicher?',
'update_the_category' => 'Kategorie aktualisieren',
'image_upload_requirements' => 'Bild-Upload ist erforderlich',
'admin' => 'Admin',
'archived' => 'Archiviert',
'user_list' => 'Benutzerliste',
'instructor_list' => 'Dozentenliste',
'course_list' => 'Kursliste',
'newsletter_list' => 'Newsletter-Liste',
'payout_list' => 'Auszahlungsliste',
'lessons' => 'Lektionen',
'quizzes' => 'Quizze',
'total_content_items' => 'Inhaltselemente gesamt',
'enrollment' => 'Einschreibung',
'course_status' => 'Kursstatus',
'update_section' => 'Abschnitt aktualisieren',
'update_lesson' => 'Lektion aktualisieren',
'update_quiz' => 'Quiz aktualisieren',
'sort_items' => 'Elemente sortieren',
'job_circulars' => 'Stellenausschreibungen',
'no_job_circulars_found' => 'Keine Stellenausschreibungen gefunden',
'provide_essential_job_details' => 'Gib die wichtigsten Details zur Stelle an',
'specify_job_requirements' => 'Gib die Anforderungen und Qualifikationen für diese Stelle an',
'provide_salary_details' => 'Gib die Gehaltsspanne und weitere Vergütungsdetails an',
'salary_information' => 'Gehaltsinformationen',
'salary_information_title' => 'Lege die Vergütungsspanne für diese Position fest',
'salary_range' => 'Gehaltsspanne',
'salary_currency' => 'Währung',
'salary_negotiable' => 'Gehalt verhandelbar',
'application_deadline' => 'Bewerbungsfrist',
'contact_email' => 'Kontakt-E-Mail',
'skills_required' => 'Erforderliche Fähigkeiten',
'positions_available' => 'Verfügbare Stellen',
'job_type' => 'Jobtyp',
'work_type' => 'Arbeitsmodell',
'experience_level' => 'Erfahrungslevel',
'job_details' => 'Jobdetails',
'job_details_title' => 'Lege Jobtyp, Standort und Erfahrungsanforderungen fest',
'all' => 'Alle',
'student' => 'Teilnehmer',
'send_to' => 'Senden an',
'send_newsletter' => 'Newsletter senden',
'no_newsletters_found' => 'Keine Newsletter gefunden',
'available' => 'Verfügbar',
'total_payout' => 'Auszahlungen gesamt',
'requested' => 'Angefragt',
'withdraw_list' => 'Auszahlungsliste',
'payout_history' => 'Auszahlungsverlauf',
'payout_request' => 'Auszahlungsanfrage',
'feedback' => 'Feedback',
'course_instructor' => 'Kursdozent',
'approval_status' => 'Freigabestatus',
'blog' => 'Blog',
'add_new_blog' => 'Neuen Blog hinzufügen',
'provide_essential_details' => 'Gib die wichtigsten Details zu deinem Blogbeitrag an',
'blog_categories' => 'Blogkategorien',
'blog_category' => 'Blogkategorie',
'protected_category' => 'Geschützte Kategorie',
'default_category_description' => 'Wenn eine Kategorie gelöscht wird, werden alle Kurse dieser Kategorie in diese Standardkategorie verschoben. Daher kann die Standardkategorie nicht bearbeitet oder entfernt werden.',
'protected_category_desc' => 'Wenn eine Kategorie gelöscht wird, werden alle Kurse dieser Kategorie in diese Standardkategorie verschoben. Daher kann die Standardkategorie nicht bearbeitet oder entfernt werden.',
'icon_picker' => 'Icon-Auswahl',
'sort_categories' => 'Kategorien sortieren',
'add_category' => 'Kategorie hinzufügen',
'create_category' => 'Kategorie erstellen',
'add_new_category' => 'Neue Kategorie hinzufügen',
'enter_blog_title' => 'Blogtitel eingeben',
'blog_information_desc' => 'Gib die wichtigsten Details zu deinem Blogbeitrag an',
'enter_category_name' => 'Kategorienamen eingeben',
'pick_category_icon' => 'Wähle dein Kategorien-Icon',
'category_status' => 'Kategoriestatus',
'all_users' => 'Alle Benutzer',
'only_students' => 'Nur Teilnehmer',
'only_instructors' => 'Nur Dozenten',
'pending_withdrawals' => 'Ausstehende Auszahlungen',
'available_balance' => 'Verfügbares Guthaben',
'withdraw_amount' => 'Auszahlungsbetrag',
'minimum_withdraw' => 'Mindestbetrag',
'maximum_withdraw' => 'Höchstbetrag',
'withdrawal_method' => 'Auszahlungsmethode',
'withdrawal_note' => 'Auszahlungsnotiz',
'withdrawal_note_placeholder' => 'Notiz zu dieser Auszahlung hinzufügen (optional)',
'user_role' => 'Benutzerrolle',
'provide_essential_user_details' => 'Gib die wichtigsten Details zum Benutzer an',
'media_library' => 'Medienbibliothek',
'file_manager' => 'Dateimanager',
'upload_media' => 'Medien hochladen',
'file_name' => 'Dateiname',
'file_size' => 'Dateigröße',
'file_type' => 'Dateityp',
'upload_date' => 'Upload-Datum',
'dimensions' => 'Abmessungen',
'notifications' => 'Benachrichtigungen',
'messages' => 'Nachrichten',
'announcements' => 'Ankündigungen',
'meta_title' => 'Meta-Titel',
'enter_meta_title' => 'Meta-Titel eingeben',
'meta_keywords' => 'Meta-Keywords',
'enter_meta_keywords' => 'Meta-Keywords eingeben',
'meta_description' => 'Meta-Beschreibung',
'enter_meta_description' => 'Meta-Beschreibung eingeben',
'og_title' => 'OG-Titel',
'enter_og_title' => 'OG-Titel eingeben',
'og_description' => 'OG-Beschreibung',
'enter_og_description' => 'OG-Beschreibung eingeben',
'lesson_type' => 'Lektionstyp',
'video_file' => 'Video-Datei',
'video_url' => 'Video-URL',
'document_file' => 'Dokument-Datei',
'image_file' => 'Bild-Datei',
'text_content' => 'Textinhalt',
'embed_source' => 'Einbettungsquelle',
'lesson_title' => 'Lektionstitel',
'lesson_status' => 'Lektionsstatus',
'is_free' => 'Kostenlos',
'lesson_description' => 'Lektionsbeschreibung',
'lesson_provider' => 'Anbieter',
'lesson_source' => 'Quelle',
'duration' => 'Dauer',
'lesson_summary' => 'Lektionszusammenfassung',
'quiz_title' => 'Quiztitel',
'enter_quiz_title' => 'Quiztitel eingeben',
'total_mark' => 'Gesamtpunktzahl',
'pass_mark' => 'Bestehensgrenze',
'retake' => 'Wiederholen',
'quiz_summary' => 'Quiz-Zusammenfassung',
'hours' => 'Stunden',
'minutes' => 'Minuten',
'seconds' => 'Sekunden',
'question_type' => 'Fragetyp',
'single_choice' => 'Einzelauswahl',
'multiple_choice' => 'Mehrfachauswahl',
'true_false' => 'Wahr oder Falsch',
'select_question_type' => 'Fragetyp auswählen',
'question_title' => 'Fragetitel',
'question_options' => 'Antwortoptionen',
'correct_answer' => 'Richtige Antwort',
'add_question' => 'Frage hinzufügen',
'edit_question' => 'Frage bearbeiten',
'sections' => 'Abschnitte',
'section_title' => 'Abschnittstitel',
'section_description' => 'Abschnittsbeschreibung',
'lesson_content' => 'Lektionsinhalt',
'quiz_questions' => 'Quizfragen',
'assignments' => 'Aufgaben',
'user_roles' => 'Benutzerrollen',
'permissions' => 'Berechtigungen',
'user_activity' => 'Benutzeraktivität',
'login_history' => 'Anmeldeverlauf',
'user_preferences' => 'Benutzereinstellungen',
'update_user' => 'Benutzer aktualisieren',
'select_approval_status' => 'Freigabestatus auswählen',
'course_progress' => 'Kursfortschritt',
'completion_rate' => 'Abschlussquote',
'time_spent' => 'Aufgewendete Zeit',
'quiz_scores' => 'Quiz-Ergebnisse',
'certificates_issued' => 'Ausgestellte Zertifikate',
'admin_revenue' => 'Admin-Umsatz',
'instructor_revenue' => 'Dozentenumsatz',
'revenue_tracking' => 'Umsatzverfolgung',
'commission_rates' => 'Provisionssätze',
'payment_history' => 'Zahlungsverlauf',
'instructor_revenue_this_year' => 'Dozentenumsatz dieses Jahr',
'admin_revenue_this_year' => 'Admin-Umsatz dieses Jahr',
'content_review' => 'Inhaltsprüfung',
'flagged_content' => 'Gemeldete Inhalte',
'moderation_queue' => 'Moderationswarteschlange',
'content_guidelines' => 'Inhaltsrichtlinien',
'help_desk' => 'Helpdesk',
'support_tickets' => 'Support-Tickets',
'documentation' => 'Dokumentation',
'marketing_campaigns' => 'Marketingkampagnen',
'promotional_codes' => 'Aktionscodes',
'affiliate_program' => 'Affiliate-Programm',
'email_marketing' => 'E-Mail-Marketing',
'third_party_integrations' => 'Drittanbieter-Integrationen',
'api_management' => 'API-Verwaltung',
'external_services' => 'Externe Dienste',
'published' => 'Veröffentlicht',
'draft' => 'Entwurf',
'active' => 'Aktiv',
'inactive' => 'Inaktiv',
'category_required' => 'Kategorie *',
'select_course_instructor' => 'Kursdozent auswählen',
'status_required' => 'Status *',
'price' => 'Preis',
'enter_course_title' => 'Kurstitel eingeben',
'enter_short_description' => 'Kurzbeschreibung eingeben',
'type_content_here' => 'Gib deinen Inhalt hier ein...',
'type_caption_optional' => 'Bildunterschrift eingeben (optional)',
'course_level' => 'Kurslevel',
'course_language' => 'Kurssprache',
'pricing_type_required' => 'Preistyp *',
'enter_course_price' => 'Kurspreis eingeben ($0)',
'check_course_discount' => 'Angeben, ob dieser Kurs einen Rabatt hat',
'enter_discount_price' => 'Rabattpreis eingeben',
'expiry_type' => 'Ablauftyp',
'expiry_duration' => 'Ablaufdauer',
'drip_content' => 'Drip-Content',
'enable_drip_content' => 'Drip-Content aktivieren',
'select_instructor' => 'Kursdozent auswählen',
'course_category' => 'Kurskategorie',
'select_category' => 'Kurskategorie auswählen',
'course_preview' => 'Kursvorschau',
'course_player' => 'Kurs-Player',
'submit_for_approval' => 'Zur Freigabe einreichen',
'course_approval_status' => 'Kurs-Freigabestatus',
'course_ready_approval' => 'Dieser Kurs ist bereit zur Freigabe!',
'course_needs_attention' => 'Dieser Kurs benötigt vor der Freigabe noch Aufmerksamkeit:',
'course_content_summary' => 'Zusammenfassung der Kursinhalte',
'course_faqs' => 'Kurs-FAQs',
'requirements' => 'Voraussetzungen',
'outcomes' => 'Ergebnisse',
'live_classes' => 'Live-Kurse',
'schedule_new_live_class' => 'Neuen Live-Kurs planen',
'schedule_live_class' => 'Live-Kurs planen',
'zoom_not_enabled' => 'Zoom ist für diesen Kurs nicht aktiviert. Bitte aktiviere Zoom, um Live-Kurse zu planen.',
'enable_zoom' => 'Zoom aktivieren',
'no_live_classes_scheduled' => 'Keine Live-Kurse geplant',
'schedule_first_live_class' => 'Plane deinen ersten Live-Kurs, um mit Zoom loszulegen.',
'class_topic_required' => 'Kursthema *',
'enter_class_topic' => 'Kursthema eingeben',
'start_date_time_required' => 'Startdatum & Uhrzeit *',
'class_notes_optional' => 'Kursnotizen (optional)',
'scheduling' => 'Wird geplant...',
'schedule_class' => 'Kurs planen',
'join_class' => 'Kurs beitreten',
'edit_class' => 'Kurs bearbeiten',
'delete_class' => 'Kurs löschen',
'banner' => 'Banner',
'thumbnail' => 'Thumbnail',
'preview_video_type' => 'Vorschau-Video-Typ',
'preview_video_url' => 'Vorschau-Video-URL',
'enter_video_url' => 'Video-URL eingeben',
'total_number_of_blog' => 'Gesamtzahl der Blogs',
'update_category' => 'Kategorie aktualisieren',
'provide_blog_details' => 'Gib die wichtigsten Details zu deinem Blogbeitrag an',
'title_80_char' => 'Titel (80 Zeichen)',
'keywords_80_char' => 'Keywords (80 Zeichen)',
'enter_your_keywords' => 'Gib deine Keywords ein',
'write_blog_content_here' => 'Schreibe deinen Blog-Inhalt hier...',
'media_files' => 'Mediendateien',
'upload_banner_thumbnail_desc' => 'Lade Banner- und Thumbnail-Bilder für deinen Blog hoch',
'blog_banner' => 'Blog-Banner',
'blog_thumbnail' => 'Blog-Thumbnail',
'update_blog' => 'Blog aktualisieren',
'add_blog' => 'Blog hinzufügen',
'subtitle_80_char' => 'Untertitel (80 Zeichen)',
'enter_category_description' => 'Kategoriebeschreibung eingeben',
'course_title' => 'Kurstitel',
'short_description' => 'Kurzbeschreibung',
'made_in' => 'Hergestellt in',
'pricing_type' => 'Preistyp',
'expiry_period_type' => 'Ablaufzeitraum-Typ',
'expiry_date' => 'Ablaufdatum',
'preview_video' => 'Vorschauvideo',
'lesson_duration' => 'Lektionsdauer',
'class_topic' => 'Kursthema',
'start_date_time' => 'Startdatum & Uhrzeit',
'class_notes' => 'Kursnotizen (optional)',
'zoom_not_enabled_message' => 'Zoom ist für diesen Kurs nicht aktiviert. Bitte aktiviere Zoom, um Live-Kurse zu planen.',
'live' => 'live',
'upcoming' => 'bevorstehend',
'ended' => 'beendet',
'scheduled' => 'geplant',
];

215
lang/de/frontend.php Normal file
View File

@ -0,0 +1,215 @@
<?php
return array (
'replies' => 'Antworten',
'posting' => 'Wird veröffentlicht...',
'replying' => 'Antwort wird gesendet...',
'something_went_wrong' => 'Etwas ist schiefgelaufen. Bitte versuche es erneut.',
'network_error' => 'Netzwerkfehler. Bitte überprüfe deine Verbindung.',
'invalid_file_type' => 'Ungültiger Dateityp. Bitte wähle eine gültige Datei aus.',
'no_element_available' => 'Kein Element verfügbar',
'delete_warning' => 'Bist du sicher, dass du löschen möchtest?',
'all' => 'Alle',
'grid_view' => 'Rasteransicht',
'list_view' => 'Listenansicht',
'notification_list' => 'Benachrichtigungsliste',
'no_unread_notifications' => 'Keine ungelesenen Benachrichtigungen',
'closed' => 'Geschlossen',
'company_fallback' => 'TechCorp Inc.',
'join_class' => 'Kurs beitreten',
'no_lesson_found' => 'Keine Lektion gefunden',
'section' => 'Abschnitt',
'section_properties' => 'Abschnittseigenschaften',
'job_circulars' => 'Stellenausschreibungen',
'profile_updated' => 'Profil wurde erfolgreich aktualisiert',
'email_changed' => 'E-Mail wurde erfolgreich geändert',
'password_changed' => 'Passwort wurde erfolgreich geändert',
'application_submitted' => 'Bewerbung wurde erfolgreich eingereicht',
'comment_posted' => 'Kommentar wurde erfolgreich veröffentlicht',
'reply_posted' => 'Antwort wurde erfolgreich veröffentlicht',
'email_not_verified' => 'Deine E-Mail-Adresse ist noch nicht bestätigt. Bitte bestätige sie, indem du auf den Link klickst, den wir dir gerade per E-Mail gesendet haben.',
'verification_link_sent' => 'Ein neuer Bestätigungslink wurde an die E-Mail-Adresse gesendet, die du bei der Registrierung angegeben hast.',
'privacy_policy' => 'Datenschutzerklärung',
'terms_of_service' => 'Nutzungsbedingungen',
'student_dashboard' => 'Teilnehmer-Dashboard',
'first_page' => 'Erste Seite',
'last_page' => 'Letzte Seite',
'sort_by' => 'Sortieren nach',
'showing_results' => 'Zeige :from bis :to von :total Ergebnissen',
'star' => 'Stern',
'stars' => 'Sterne',
'edit_review' => 'Bewertung bearbeiten',
'review' => 'Bewertung',
'submit_review' => 'Bewertung absenden',
'you_rated_this' => 'Du hast dies bewertet',
'characters' => 'Zeichen',
'no_courses_found' => 'Keine Kurse gefunden',
'no_wishlist_items' => 'Keine Einträge in der Wunschliste gefunden',
'day_left' => 'Noch 1 Tag',
'days_left' => 'Noch :days Tage',
'negotiable' => 'Verhandelbar',
'experience_level' => 'Erfahrungslevel',
'job_type' => 'Jobtyp',
'work_type' => 'Arbeitsmodell',
'location' => 'Standort',
'positions_available' => 'Verfügbare Stellen',
'application_deadline' => 'Bewerbungsfrist',
'contact_email' => 'Kontakt-E-Mail',
'skills_required' => 'Erforderliche Fähigkeiten',
'job_description' => 'Stellenbeschreibung',
'quick_apply' => 'Schnell bewerben',
'send_application' => 'Sende deine Bewerbung direkt an unser Team',
'apply_via_email' => 'Per E-Mail bewerben',
'job_statistics' => 'Job-Statistiken',
'posted' => 'Veröffentlicht',
'last_updated' => 'Zuletzt aktualisiert',
'tax' => 'Steuer',
'total' => 'Gesamt',
'cart_items' => 'Warenkorb-Artikel',
'your_cart_is_empty' => 'Dein Warenkorb ist leer',
'payment_summary' => 'Zahlungsübersicht',
'sub_total' => 'Zwischensumme',
'discount' => 'Rabatt',
'all_blogs' => 'Alle Blogs',
'latest_blog_posts' => 'Neueste Blogbeiträge',
'post_a_comment' => 'Kommentar schreiben',
'no_comments_yet' => 'Noch keine Kommentare. Sei der Erste, der kommentiert!',
'like' => 'Gefällt mir',
'dislike' => 'Gefällt mir nicht',
'blog_banner_alt' => 'Blog-Banner',
'blog_thumbnail_alt' => 'Blog-Thumbnail',
'author_alt' => 'Autor',
'author_initials_fallback' => 'AU',
'blog_list_alt' => 'Blogliste',
'blog_page_description' => 'Lies :total+ Artikel und Tutorials von unseren Dozenten und unserem Team. Bleib mit Einblicken, News und How-to-Guides auf dem Laufenden.',
'blog_page_keywords' => 'blogs, artikel, tutorials, news, posts, lernen, bildung',
'default_site_name' => 'Mentor Learning Management System',
'loading_zoom_sdk' => 'Zoom SDK wird geladen...',
'joining_meeting' => 'Meeting wird beigetreten...',
'unable_to_join_meeting' => 'Meetingbeitritt nicht möglich',
'you_can_join_directly' => 'Du kannst direkt beitreten über:',
'open_in_zoom_app' => 'In Zoom-App öffnen',
'try_again' => 'Erneut versuchen',
'zoom_sdk_not_configured' => 'Zoom SDK ist nicht konfiguriert. Bitte kontaktiere den Administrator.',
'meeting_information_not_found' => 'Meeting-Informationen nicht gefunden.',
'failed_to_get_meeting_configuration' => 'Meeting-Konfiguration konnte nicht abgerufen werden',
'zoom_sdk_not_loaded' => 'Zoom SDK nicht geladen',
'failed_to_initialize_meeting' => 'Meeting konnte nicht initialisiert werden',
'failed_to_join_meeting' => 'Meetingbeitritt fehlgeschlagen',
'course_certificate_download' => 'Kurszertifikat herunterladen',
'download_official_certificate' => 'Lade dein offizielles Kursabschlusszertifikat herunter',
'certificate_of_completion' => 'Abschlusszertifikat',
'certificate_description' => 'Dieses Zertifikat wird mit Stolz überreicht, um den erfolgreichen Abschluss aller Kursanforderungen anzuerkennen und ein starkes Engagement für berufliche Weiterentwicklung und exzellentes Lernen zu bestätigen. Hiermit wird bescheinigt, dass',
'has_successfully_completed' => 'den Kurs erfolgreich abgeschlossen hat',
'completed_on' => 'Abgeschlossen am: :date',
'authorized_certificate' => 'Autorisierte Leistungsurkunde',
'download_format' => 'Download-Format',
'png_certificate_saved' => 'Dein PNG-Zertifikat wurde in deinem Download-Ordner gespeichert.',
'pdf_certificate_saved' => 'Dein PDF-Zertifikat wurde in deinem Download-Ordner gespeichert.',
'summery' => 'Zusammenfassung',
'duration' => 'Dauer',
'total_questions' => 'Fragen gesamt',
'total_marks' => 'Punkte gesamt',
'pass_marks' => 'Bestehensgrenze',
'retake' => 'Wiederholen',
'result' => 'Ergebnis',
'retake_attempts' => 'Wiederholungsversuche',
'correct_answers' => 'Richtige Antworten',
'incorrect_answers' => 'Falsche Antworten',
'passed' => 'Bestanden',
'not_passed' => 'Nicht bestanden',
'quiz_submitted' => 'Quiz abgeschickt',
'start_quiz' => 'Quiz starten',
'retake_quiz' => 'Quiz wiederholen',
'true' => 'Wahr',
'false' => 'Falsch',
'hours' => 'Stunden',
'minutes' => 'Minuten',
'seconds' => 'Sekunden',
'pdf_document' => 'PDF-Dokument',
'text_document' => 'Textdokument',
'document' => 'Dokument',
'open_in_new_tab' => 'In neuem Tab öffnen',
'download_document' => 'Dokument herunterladen',
'unsupported_document_format' => 'Nicht unterstütztes Dokumentformat',
'document_format_cannot_be_previewed' => 'Dieses Dokumentformat (.{extension}) kann nicht direkt angezeigt werden. Du kannst es herunterladen, um es mit einer geeigneten Anwendung zu öffnen.',
'open_in_new_tab_button' => 'In neuem Tab öffnen',
'download' => 'Herunterladen',
'learn_comprehensive_course' => 'Lerne mit unserem umfassenden Kurs',
'online_course_learning_lms' => 'Onlinekurs, Lernen, LMS',
'default_author' => 'UiLib',
'course_certificate' => 'Kurszertifikat',
'enrolled_students' => 'Eingeschriebene Teilnehmer',
'student_reviews' => 'Teilnehmerbewertungen',
'no_reviews_found' => 'Keine Bewertungen gefunden.',
'course_curriculum' => 'Kurslehrplan',
'there_is_no_lesson_added' => 'Es wurde keine Lektion hinzugefügt',
'requirements' => 'Voraussetzungen',
'outcomes' => 'Ergebnisse',
'view_details' => 'Details ansehen',
'students' => 'Teilnehmer',
'language' => 'Sprache',
'level' => 'Level',
'expiry_period' => 'Ablaufzeitraum',
'certificate_included' => 'Zertifikat enthalten',
'free' => 'Kostenlos',
'play_course' => 'Kurs starten',
'course_player' => 'Kurs-Player',
'enroll_now' => 'Jetzt einschreiben',
'buy_now' => 'Jetzt kaufen',
'add_to_cart' => 'In den Warenkorb',
'add_to_wishlist' => 'Zur Wunschliste hinzufügen',
'remove_from_wishlist' => 'Von der Wunschliste entfernen',
'prev' => 'Zurück',
'next' => 'Weiter',
'go_to_page_colon' => 'Gehe zu Seite:',
'student' => 'Teilnehmer',
'trending' => 'Im Trend',
'course_details' => 'Kursdetails',
'progress' => 'Fortschritt',
'instructor' => 'Dozent',
'all_courses' => 'Alle Kurse',
'instructor_profile' => 'Dozentenprofil',
'expert_instructor' => 'Experten-Dozent',
'instructor_fallback_keywords' => 'lernen, bildung',
'application_is' => 'Bewerbung ist',
'application_status' => 'Bewerbungsstatus',
'application_rejected' => 'Leider wurde deine Bewerbung abgelehnt. Bitte prüfe die Anforderungen und reiche sie mit aktualisierten Informationen erneut ein.',
'application_under_review' => 'Deine Bewerbung wird derzeit von unserem Team geprüft. Wir melden uns so schnell wie möglich bei dir.',
'position' => 'Stelle',
'positions' => 'Stellen',
'available' => 'verfügbar',
'dashboard' => 'Dashboard',
'courses' => 'Kurse',
'lessons' => 'Lektionen',
'enrollment' => 'Einschreibung',
'course_status' => 'Kursstatus',
'latest_pending_withdrawal_request' => 'Neueste ausstehende Auszahlungsanfrage',
'view_all' => 'Alle anzeigen',
'no_results' => 'Keine Ergebnisse.',
'php_extension' => 'PHP-Erweiterung',
'symlink_function' => 'Symlink-Funktion',
'server_requirements_not_met' => 'Dein Server erfüllt die folgenden Anforderungen nicht',
'important_notes' => 'Wichtige Hinweise',
'symlink_required' => 'Erforderlich für Laravels storage:link-Befehl, um hochgeladene Dateien öffentlich zugänglich zu machen',
'setup_complete' => 'Einrichtung abgeschlossen',
'environment_variables_set' => 'Deine geänderten Umgebungsvariablen sind jetzt in der .env-Datei gesetzt.',
'click_here' => 'Klicke hier',
'get_back_to_project' => 'um zu deinem Projekt zurückzukehren.',
'test_connection' => 'Verbindung testen',
'database_connection' => 'Datenbankverbindung',
'environment_setup' => 'Umgebung einrichten',
'admin_setup' => 'Admin-Einrichtung',
'just_now' => 'Gerade eben',
'minute_ago' => 'Vor 1 Minute',
'minutes_ago' => 'Vor :minutes Minuten',
'hour_ago' => 'Vor 1 Stunde',
'hours_ago' => 'Vor :hours Stunden',
'days_ago' => 'Vor :days Tagen',
'week_ago' => 'Vor 1 Woche',
'weeks_ago' => 'Vor :weeks Wochen',
'month_ago' => 'Vor 1 Monat',
'months_ago' => 'Vor :months Monaten',
'year_ago' => 'Vor 1 Jahr',
'years_ago' => 'Vor :years Jahren',
);

348
lang/de/input.php Normal file
View File

@ -0,0 +1,348 @@
<?php
return [
'email' => 'E-Mail',
'new_email' => 'Neue E-Mail',
'your_email' => 'Deine E-Mail',
'remember_me' => 'Angemeldet bleiben',
'email_address' => 'E-Mail-Adresse',
'current_email' => 'Aktuelle E-Mail',
'password' => 'Passwort',
'current_password' => 'Aktuelles Passwort',
'new_password' => 'Neues Passwort',
'confirm_password' => 'Passwort bestätigen',
'confirm_new_password' => 'Neues Passwort bestätigen',
'full_name' => 'Vollständiger Name',
'phone' => 'Telefon',
'username' => 'Benutzername',
'profile_url' => 'Profil-URL',
'system_email' => 'System-E-Mail',
'account_email' => 'Konto-E-Mail',
'contact_email' => 'Kontakt-E-Mail',
'from_address' => 'Absenderadresse',
'from_name' => 'Absendername',
'allow_file_type' => 'Erlaubte Dateitypen',
'url' => 'URL',
'name' => 'Name',
'title' => 'Titel',
'status' => 'Status',
'icon' => 'Icon',
'description' => 'Beschreibung',
'sub_title' => 'Untertitel',
'thumbnail' => 'Vorschaubild',
'banner' => 'Banner',
'background_image' => 'Hintergrundbild',
'background_color' => 'Hintergrundfarbe',
'list_items' => 'Listeneinträge',
'dropdown_items' => 'Dropdown-Einträge',
'action_type' => 'Aktionstyp',
'active' => 'Aktiv',
'tags' => 'Tags',
'slug' => 'Slug',
'url_slug' => 'URL-Slug',
'slogan' => 'Slogan',
'author' => 'Autor',
'meta_title' => 'Meta-Titel',
'meta_keywords' => 'Meta-Keywords',
'meta_description' => 'Meta-Beschreibung',
'og_title' => 'OG-Titel',
'og_description' => 'OG-Beschreibung',
'student_name' => 'Teilnehmername',
'course_name' => 'Kursname',
'completion_date' => 'Abschlussdatum',
'certificate_size' => 'Zertifikatsgröße',
'short_description' => 'Kurzbeschreibung',
'download_format' => 'Download-Format',
'preview_video' => 'Vorschauvideo',
'preview_video_type' => 'Vorschauvideo-Typ',
'video_url' => 'Video-URL',
'video_url_provider' => 'Video-URL-Anbieter',
'select' => 'Auswählen',
'select_provider' => 'Anbieter auswählen',
'select_video' => 'Video auswählen',
'select_document' => 'Dokument auswählen',
'select_image' => 'Bild auswählen',
'select_user' => 'Benutzer auswählen',
'select_course' => 'Kurs auswählen',
'embed_source' => 'Embed-Quelle',
'duration' => 'Dauer',
'lesson_type' => 'Lektionstyp',
'requirement' => 'Voraussetzung',
'class_topic' => 'Kursthema',
'start_date_time' => 'Startdatum & Uhrzeit',
'summary' => 'Zusammenfassung',
'class_notes' => 'Kursnotizen (optional)',
'enable_drip_content' => 'Drip-Content aktivieren',
'course_level' => 'Kurslevel',
'course_instructor' => 'Dozent',
'made_in' => 'Hergestellt in',
'pricing_type' => 'Preismodell',
'price' => 'Preis',
'expiry_period_type' => 'Ablaufzeitraum-Typ',
'expiry_date' => 'Ablaufdatum',
'expiry_duration' => 'Ablaufdauer',
'course_language' => 'Kurs-Sprache',
'course_discount' => 'Kursrabatt',
'enrollment_type' => 'Einschreibetyp',
'question_title' => 'Fragetitel',
'title_80_character' => 'Titel (80 Zeichen)',
'keywords' => 'Keywords',
'keywords_80_character' => 'Keywords (80 Zeichen)',
'subtitle_80_character' => 'Untertitel (80 Zeichen)',
'question' => 'Frage',
'answer' => 'Antwort',
'outcome' => 'Ergebnis',
'your_text' => 'Gib deinen Text ein',
'question_type' => 'Fragetyp',
'options' => 'Optionen',
'hours' => 'Stunden',
'minutes' => 'Minuten',
'seconds' => 'Sekunden',
'total_mark' => 'Gesamtpunkte',
'pass_mark' => 'Bestehensgrenze',
'retake_attempts' => 'Wiederholungsversuche',
'section_title' => 'Abschnittstitel',
'feedback' => 'Feedback',
'review' => 'Deine Bewertung',
'rating' => 'Bewertung',
'designation' => 'Bezeichnung',
'resume' => 'Lebenslauf',
'skills' => 'Skills',
'skills_required' => 'Erforderliche Skills',
'biography' => 'Biografie',
'job_title' => 'Jobtitel',
'job_description' => 'Stellenbeschreibung',
'job_type' => 'Jobtyp',
'work_type' => 'Arbeitsmodell',
'experience_level' => 'Erfahrungslevel',
'positions_available' => 'Offene Stellen',
'location' => 'Standort',
'application_deadline' => 'Bewerbungsfrist',
'salary_is_negotiable' => 'Gehalt ist verhandelbar',
'minimum_salary' => 'Mindestgehalt',
'maximum_salary' => 'Höchstgehalt',
'currency' => 'Währung',
'subject' => 'Betreff',
'send_to' => 'Senden an',
'test_api_key' => 'Test-API-Key',
'live_api_key' => 'Live-API-Key',
'public_test_key' => 'Öffentlicher Test-Key',
'secret_test_key' => 'Geheimer Test-Key',
'public_live_key' => 'Öffentlicher Live-Key',
'secret_live_key' => 'Geheimer Live-Key',
'webhook_secret' => 'Webhook-Secret',
'sandbox_client_id' => 'Sandbox-Client-ID',
'sandbox_secret_key' => 'Sandbox-Secret-Key',
'production_client_id' => 'Production-Client-ID',
'production_secret_key' => 'Production-Secret-Key',
'test_public_key' => 'Öffentlicher Test-Key',
'test_secret_key' => 'Geheimer Test-Key',
'live_public_key' => 'Öffentlicher Live-Key',
'live_secret_key' => 'Geheimer Live-Key',
'client_id' => 'Client-ID',
'secret_key' => 'Secret-Key',
'amount' => 'Betrag',
'account_id' => 'Account-ID',
'client_secret' => 'Client-Secret',
'meeting_sdk_client_id' => 'Meeting SDK Client-ID',
'meeting_sdk_client_secret' => 'Meeting SDK Client-Secret',
'do_you_want_use_web_sdk' => 'Möchtest du das Web SDK für deinen Live-Kurs verwenden?',
'mail_driver' => 'Mail-Treiber',
'host' => 'Host',
'port' => 'Port',
'encryption' => 'Verschlüsselung',
'storage_driver' => 'Storage-Treiber',
'access_key_id' => 'Access Key ID',
'secret_access_key' => 'Secret Access Key',
'default_region' => 'Standard-Region',
'bucket_name' => 'Bucket-Name',
'api_key' => 'API-Key',
'api_secret' => 'API-Secret',
'store_id' => 'Store-ID',
'store_password' => 'Store-Passwort',
'page_contents' => 'Seiteninhalte',
'google_client_id' => 'Google Client-ID',
'google_client_secret' => 'Google Client-Secret',
'google_redirect_uri' => 'Google Redirect-URI',
'merchant_id_public_key' => 'Merchant-ID / Public Key',
'merchant_key_secret_key' => 'Merchant-Key / Secret-Key',
'website' => 'Website',
'github' => 'GitHub',
'twitter' => 'Twitter',
'linkedin' => 'LinkedIn',
'facebook' => 'Facebook',
'website_name' => 'Website-Name',
'website_title' => 'Website-Titel',
'logo_dark' => 'Logo (dunkel)',
'logo_light' => 'Logo (hell)',
'favicon' => 'Favicon',
'course_selling_currency' => 'Kurs-Verkaufswährung',
'course_selling_tax' => 'Kurs-Verkaufssteuer (%)',
'instructor_revenue' => 'Dozentenanteil (%)',
'category' => 'Kategorie',
'category_icon' => 'Kategorie-Icon',
'category_status' => 'Kategorie-Status',
'coupon' => 'Gutschein',
'select_zip_file' => 'Datei auswählen (.zip nur)',
'blog_banner' => 'Blog-Banner',
'blog_thumbnail' => 'Blog-Vorschaubild',
'select_option' => 'Option auswählen',
'mail_host' => 'Mail-Host',
'mail_port' => 'Mail-Port',
'mail_encryption' => 'Mail-Verschlüsselung',
'mail_username' => 'Mail-Benutzername',
'mail_password' => 'Mail-Passwort',
'mail_from_address' => 'Mail-Absenderadresse',
'mail_from_name' => 'Mail-Absendername',
'aws_access_key_id' => 'AWS Access Key ID',
'aws_default_region' => 'AWS Standard-Region',
// Placeholders
'email_placeholder' => 'email@example.com',
'password_placeholder' => 'Passwort eingeben',
'confirm_password_placeholder' => 'Passwort bestätigen',
'current_email_placeholder' => 'Gib deine aktuelle E-Mail ein',
'new_email_placeholder' => 'Gib deine neue E-Mail ein',
'current_password_placeholder' => 'Gib dein aktuelles Passwort ein',
'new_password_placeholder' => 'Gib dein neues Passwort ein',
'rewrite_password_placeholder' => 'Gib dein neues Passwort erneut ein',
'full_name_placeholder' => 'Gib deinen vollständigen Namen ein',
'your_name_placeholder' => 'Gib deinen Namen ein',
'username_placeholder' => 'Gib deinen Benutzernamen ein',
'phone_number_placeholder' => 'Telefonnummer eingeben',
'contact_email_placeholder' => 'hr@company.com',
'system_type_placeholder' => 'Systemtyp auswählen',
'name_placeholder' => 'Gib deinen Namen ein',
'title_placeholder' => 'Titel eingeben',
'slug_placeholder' => 'Eindeutigen Slug eingeben',
'url_placeholder' => 'URL eingeben (z.B. /courses, https://example.com)',
'description_placeholder' => 'Beschreibung eingeben',
'icon_placeholder' => 'Icon auswählen',
'status_placeholder' => 'Status auswählen',
'action_type_placeholder' => 'Aktionstyp auswählen',
'image_url_placeholder' => 'Bild-URL oder Pfad',
'section_title_placeholder' => 'Gib deinen Abschnittstitel ein',
'content_here_placeholder' => 'Gib deinen Inhalt hier ein...',
'caption_placeholder' => 'Bildunterschrift eingeben (optional)',
'meta_title_placeholder' => 'Meta-Titel eingeben',
'meta_keywords_placeholder' => 'Meta-Keywords eingeben',
'meta_description_placeholder' => 'Meta-Beschreibung eingeben',
'og_title_placeholder' => 'OG-Titel eingeben',
'og_description_placeholder' => 'OG-Beschreibung eingeben',
'page_name_placeholder' => 'Seitenname eingeben',
'page_slug_placeholder' => 'Seiten-Slug eingeben',
'page_title_placeholder' => 'Seitentitel eingeben',
'tags_placeholder' => 'Tags eingeben...',
'course_name_placeholder' => 'Kursname eingeben',
'certificate_size_placeholder' => 'Zertifikatsgröße auswählen',
'course_title_placeholder' => 'Kurstitel eingeben',
'short_description_placeholder' => 'Kurzbeschreibung eingeben',
'category_placeholder' => 'Kategorie auswählen',
'course_level_placeholder' => 'Kurslevel auswählen',
'course_language_placeholder' => 'Kurs-Sprache auswählen',
'course_price_placeholder' => 'Kurspreis eingeben ($0)',
'discount_price_placeholder' => 'Rabattpreis eingeben ($0)',
'video_url_placeholder' => 'Video-URL eingeben',
'approval_status_placeholder' => 'Freigabestatus auswählen',
'lesson_title_placeholder' => 'Lektionstitel',
'provider_placeholder' => 'Anbieter auswählen',
'type_video_url_placeholder' => 'Video-URL eingeben',
'embed_source_placeholder' => 'Embed-Quell-URL eingeben',
'duration_placeholder' => '00:00:00',
'class_topic_placeholder' => 'Kursthema eingeben',
'quiz_title_placeholder' => 'Quiz-Titel eingeben',
'instructor_placeholder' => 'Dozent auswählen',
'expiry_duration_placeholder' => 'Dauer auswählen',
'question_placeholder' => 'Frage',
'answer_placeholder' => 'Antwort eingeben',
'outcome_placeholder' => 'Ergebnis',
'question_type_placeholder' => 'Fragetyp auswählen',
'question_options_placeholder' => 'Antwortmöglichkeiten eingeben',
'answer_options_placeholder' => 'Antwortoptionen eingeben',
'hours_placeholder' => '00 Stunden',
'minutes_placeholder' => '00 Minuten',
'seconds_placeholder' => '00 Sekunden',
'experience_placeholder' => 'Teile deine Erfahrung mit diesem Kurs...',
'your_subject_placeholder' => 'Betreff eingeben',
'review_placeholder' => 'Bewertung eingeben',
'biography_placeholder' => 'Schreibe etwas über dich',
'designation_placeholder' => 'Bezeichnung eingeben',
'skills_tag_placeholder' => 'Skills als Tag eingeben',
'about_yourself_placeholder' => 'Schreibe etwas über dich',
'job_title_placeholder' => 'z.B. Senior React Developer',
'url_slug_placeholder' => 'senior-react-developer',
'job_description_placeholder' => 'Beschreibe die Rolle, Erwartungen und was die Stelle spannend macht...',
'location_placeholder' => 'z.B. Dhaka, Bangladesch',
'currency_placeholder' => 'Währung auswählen',
'minimum_salary_placeholder' => 'Mindestgehalt eingeben',
'maximum_salary_placeholder' => 'Höchstgehalt eingeben',
'store_id_placeholder' => 'Store-ID eingeben',
'store_password_placeholder' => 'Store-Passwort eingeben',
'api_key_placeholder' => 'API-Key eingeben',
'api_secret_placeholder' => 'API-Secret eingeben',
'test_api_key_placeholder' => 'Test-API-Key eingeben',
'live_api_key_placeholder' => 'Live-API-Key eingeben',
'mollie_test_api_key_placeholder' => 'Mollie Test API-Key eingeben',
'mollie_live_api_key_placeholder' => 'Mollie Live API-Key eingeben',
'sandbox_client_id_placeholder' => 'Sandbox Client-ID eingeben',
'production_client_id_placeholder' => 'Production Client-ID eingeben',
'sandbox_secret_key_placeholder' => 'Sandbox Secret-Key eingeben',
'production_secret_key_placeholder' => 'Production Secret-Key eingeben',
'test_public_key_placeholder' => 'Öffentlichen Test-Key eingeben',
'test_secret_key_placeholder' => 'Geheimen Test-Key eingeben',
'live_public_key_placeholder' => 'Öffentlichen Live-Key eingeben',
'live_secret_key_placeholder' => 'Geheimen Live-Key eingeben',
'webhook_secret_placeholder' => 'Webhook-Secret eingeben',
'your_amount_placeholder' => 'Betrag eingeben',
'paytm_merchant_id_placeholder' => 'Paytm Merchant-ID eingeben',
'paytm_merchant_key_placeholder' => 'Paytm Merchant-Key eingeben',
'razorpay_public_key_placeholder' => 'Razorpay Public Key eingeben',
'razorpay_secret_key_placeholder' => 'Razorpay Secret Key eingeben',
'sslcommerz_public_key_placeholder' => 'SSLCommerz Public Key eingeben',
'sslcommerz_secret_key_placeholder' => 'SSLCommerz Secret Key eingeben',
'coupon_placeholder' => 'Gutschein eingeben',
'zoom_account_email_placeholder' => 'Zoom Konto-E-Mail eingeben',
'zoom_account_id_placeholder' => 'Zoom Account-ID eingeben',
'zoom_client_id_placeholder' => 'Zoom Client-ID eingeben',
'zoom_client_secret_placeholder' => 'Zoom Client-Secret eingeben',
'meeting_sdk_client_id_placeholder' => 'Meeting SDK Client-ID eingeben',
'meeting_sdk_client_secret_placeholder' => 'Meeting SDK Client-Secret eingeben',
'mail_driver_placeholder' => 'Mail-Treiber auswählen',
'smtp_example_placeholder' => 'smtp.example.com',
'port_587_placeholder' => '587',
'encryption_placeholder' => 'Verschlüsselung auswählen',
'noreply_example_placeholder' => 'noreply@example.com',
'your_company_name_placeholder' => 'Dein Firmenname',
'storage_driver_placeholder' => 'Storage-Treiber auswählen',
'access_key_id_placeholder' => 'Access Key ID eingeben',
'secret_access_key_placeholder' => 'Secret Access Key eingeben',
'default_region_placeholder' => 'Standard-Region eingeben',
'bucket_name_placeholder' => 'Bucket-Name eingeben',
'google_client_id_placeholder' => 'Google Client-ID eingeben',
'google_client_secret_placeholder' => 'Google Client-Secret eingeben',
'google_redirect_url_placeholder' => 'Google Redirect-URL eingeben',
'https_placeholder' => 'https://example.com',
'website_name_placeholder' => 'Website-Name eingeben',
'website_title_placeholder' => 'Website-Titel eingeben',
'keywords_placeholder' => 'Keywords eingeben',
'website_description_placeholder' => 'Website-Beschreibung eingeben',
'author_name_placeholder' => 'Autorname eingeben',
'website_slogan_placeholder' => 'Website-Slogan eingeben',
'system_email_placeholder' => 'System-E-Mail eingeben',
'select_logo_placeholder' => 'Logo auswählen',
'select_favicon_placeholder' => 'Favicon auswählen',
'select_banner_placeholder' => 'Banner auswählen',
'select_user_placeholder' => 'Benutzer auswählen',
'select_course_placeholder' => 'Kurs auswählen',
'selling_currency_placeholder' => 'Verkaufswährung auswählen',
'selling_tax_percentage_placeholder' => 'Verkaufssteuer (%) eingeben',
'revenue_percentage_placeholder' => 'Dozentenanteil (%) eingeben',
'mail_host_placeholder' => 'smtp.example.com',
'mail_port_placeholder' => '587',
'mail_username_placeholder' => 'deine-email@example.com',
'mail_password_placeholder' => 'Mail-Passwort eingeben',
'mail_from_address_placeholder' => 'noreply@example.com',
'mail_from_name_placeholder' => 'Dein Anwendungsname',
'aws_access_key_id_placeholder' => 'AWS Access Key ID eingeben',
'aws_default_region_placeholder' => 'us-east-1',
];

6
lang/de/pagination.php Normal file
View File

@ -0,0 +1,6 @@
<?php
return [
'previous' => '&laquo; Zurück',
'next' => 'Weiter &raquo;',
];

9
lang/de/passwords.php Normal file
View File

@ -0,0 +1,9 @@
<?php
return [
'reset' => 'Dein Passwort wurde zurückgesetzt.',
'sent' => 'Wir haben dir den Link zum Zurücksetzen des Passworts per E-Mail gesendet.',
'throttled' => 'Bitte warte, bevor du es erneut versuchst.',
'token' => 'Dieser Passwort-Zurücksetzen-Token ist ungültig.',
'user' => 'Wir können keinen Benutzer mit dieser E-Mail-Adresse finden.',
];

215
lang/de/settings.php Normal file
View File

@ -0,0 +1,215 @@
<?php
return [
'social_links' => 'Social Links',
'system_settings' => 'Systemeinstellungen',
'page_settings' => 'Seiteneinstellungen',
'live_class_settings' => 'Live-Kurs-Einstellungen',
'payment_gateways' => 'Zahlungsanbieter',
'custom_global_style' => 'Benutzerdefiniertes globales Design',
'account_settings' => 'Kontoeinstellungen',
'smtp_settings' => 'SMTP-Einstellungen',
'storage_settings' => 'Speicher-Einstellungen',
'language_settings' => 'Spracheinstellungen',
'translation_settings' => 'Übersetzungseinstellungen',
'add_language' => 'Sprache hinzufügen',
'translation_scope_information' => 'Informationen zum Übersetzungsbereich',
'translation_scope_dashboard' => 'Übersetzungen werden auf die Dashboard-Oberflächen (Administrator, Dozent, Student) angewendet.',
'translation_scope_public_pages' => 'Öffentliche Seiten sind von diesen Übersetzungen nicht betroffen, da sie über den Seiteneditor vollständig anpassbar sind.',
'configure_zoom' => 'Zoom Server-to-Server OAuth-Zugangsdaten konfigurieren',
'email_settings_description' => 'Konfiguriere deine E-Mail-Versandeinstellungen',
'storage_settings_description' => 'Konfiguriere deine Speicher-Einstellungen',
'application_update' => 'Anwendungsaktualisierung',
'application_backup' => 'Anwendungs-Backup',
'application_updated' => 'Anwendung wurde erfolgreich aktualisiert',
'updating_application' => 'Anwendung wird aktualisiert...',
'backup_name' => 'Backup-Name',
'backup_date' => 'Backup-Datum',
'backup_size' => 'Größe',
'backup_status' => 'Status',
'backup_actions' => 'Aktionen',
'backup_history' => 'Backup-Verlauf',
'backup_created' => 'Backup wurde erfolgreich erstellt',
'backup_deleted' => 'Backup wurde erfolgreich gelöscht',
'backup_restored' => 'Backup wurde erfolgreich wiederhergestellt',
'backup_failed' => 'Backup-Erstellung fehlgeschlagen. Bitte versuche es erneut.',
'backup_recommendation' => 'Wir empfehlen dringend, vor dem Aktualisieren ein Backup zu erstellen.',
'maintenance_description' => 'Aktualisiere, sichere und stelle deine Anwendung sicher und automatisch wieder her.',
'update_description' => 'Lade Updates hoch und installiere sie sicher',
'backup_description' => 'Erstelle ein vollständiges Backup deiner Anwendung inkl. Dateien und Datenbank',
'backup_history_description' => 'Backups deiner Anwendung ansehen und verwalten',
'home_pages_description' => 'Liste aller Startseiten im System',
'custom_pages_description' => 'Benutzerdefinierte Seiten verwalten',
'css_description' => 'Schreibe eigenes CSS, das global auf der Website angewendet wird.',
'system_settings_description' => 'Kerneinstellungen deines Systems verwalten',
'translation_description' => 'Spracheigenschaften übersetzen',
'edit_custom_page' => 'Benutzerdefinierte Seite bearbeiten',
'translation_update' => 'Übersetzungsaktualisierung',
'elements' => 'Elemente',
'available_home_pages' => 'Verfügbare Startseiten',
// Application Update
'confirm_application_update' => 'Anwendungsaktualisierung bestätigen',
'do_not_close_window' => 'Bitte schließe dieses Fenster nicht',
'update_application_with' => 'Bist du sicher, dass du die Anwendung mit ":filename" aktualisieren möchtest?',
'this_update_will' => 'Dieses Update wird:',
'put_site_maintenance' => 'Die Website in den Wartungsmodus versetzen',
'replace_application_files' => 'Alle Anwendungsdateien ersetzen',
'run_database_migrations' => 'Datenbank-Migrationen ausführen',
'process_may_take_minutes' => 'Der Vorgang kann einige Minuten dauern',
'backup_first_warning' => 'Stelle sicher, dass du vorher ein Backup erstellt hast! Diese Aktion kann nicht rückgängig gemacht werden.',
'select_zip_file' => 'Datei auswählen (.zip nur)',
'selected_file' => 'Ausgewählte Datei:',
'file_selected_successfully' => 'Datei erfolgreich ausgewählt. Klicke auf „Anwendung aktualisieren“, um fortzufahren.',
'update_application' => 'Anwendung aktualisieren',
'uploading' => 'Wird hochgeladen...',
'important_update_guidelines' => 'Wichtige Update-Hinweise',
'refresh_server_guideline' => 'Vor jedem Update den Server aktualisieren',
'backup_first_guideline' => 'Vor jedem Update immer ein Backup erstellen',
'file_format_guideline' => 'Upload muss eine gültige ZIP-Datei sein',
'maintenance_mode_guideline' => 'Die Website ist während des Updates vorübergehend nicht verfügbar',
'migrations_guideline' => 'Datenbank-Migrationen werden automatisch ausgeführt',
'downtime_guideline' => 'Der Update-Prozess kann einige Minuten dauern',
'browser_guideline' => 'Während des Updates den Browser nicht aktualisieren oder schließen',
'compatibility_guideline' => 'Stelle sicher, dass das Update mit deinem System kompatibel ist',
'refresh_server' => 'Server aktualisieren',
'backup_first' => 'Backup zuerst',
'file_format' => 'Dateiformat',
'maintenance_mode' => 'Wartungsmodus',
'migrations' => 'Migrationen',
'downtime' => 'Downtime',
'browser' => 'Browser',
'compatibility' => 'Kompatibilität',
'application_update_title' => 'Anwendungsaktualisierung',
'upload_install_description' => 'Lade die neueste Version deiner Anwendung hoch und installiere sie',
'refresh_server_button' => 'Server aktualisieren',
'updating_application_button' => 'Anwendung wird aktualisiert...',
// Payment Gateway Settings
'paypal_settings' => 'PayPal-Einstellungen',
'stripe_settings' => 'Stripe-Einstellungen',
'mollie_settings' => 'Mollie-Einstellungen',
'paystack_settings' => 'Paystack-Einstellungen',
'razorpay_settings' => 'Razorpay-Einstellungen',
'sslcommerz_settings' => 'SSLCommerz-Einstellungen',
'paytm_settings' => 'Paytm-Einstellungen',
'configure_payment_gateway' => ':gateway Zahlungsanbieter konfigurieren',
// Environment Settings
'test_mode' => 'Testmodus',
'using_test_environment' => 'Testumgebung wird verwendet',
'using_live_environment' => 'Live-Umgebung wird verwendet',
'using_sandbox_environment' => 'Sandbox-Umgebung wird verwendet',
'using_production_environment' => 'Produktivumgebung wird verwendet',
'using_staging_environment' => 'Staging-Umgebung wird verwendet',
'using_test_keys' => 'Test-Keys werden verwendet',
'using_live_keys' => 'Live-Keys werden verwendet',
// Credential Sections
'api_credentials' => 'API-Zugangsdaten',
'test_credentials' => 'Test-Zugangsdaten',
'live_credentials' => 'Live-Zugangsdaten',
'sandbox_credentials' => 'Sandbox-Zugangsdaten',
'production_credentials' => 'Produktiv-Zugangsdaten',
// Helper Messages
'use_test_mode_key' => 'Verwende deinen Testmodus-Key :key',
'use_live_mode_key' => 'Verwende deinen Live-Key :key',
'use_staging_key' => 'Verwende deinen Staging-Key :key',
'use_production_key' => 'Verwende deinen Produktiv-Key :key',
// Warnings / Errors
'delete_backup_warning' => 'Bist du sicher, dass du dieses Backup löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.',
'restore_backup_warning' => 'Bist du sicher, dass du dieses Backup wiederherstellen möchtest? Dies wird:',
'system_type_warning' => 'Bist du sicher, dass du den Systemtyp ändern möchtest? Das beeinflusst das gesamte Verhalten der Anwendung.',
'update_system_type_warning' => 'Bist du sicher, dass du den Systemtyp aktualisieren möchtest?',
'update_warning' => 'Dieses Update wird:',
'update_failed' => 'Update fehlgeschlagen. Bitte versuche es erneut.',
'restore_failed' => 'Backup-Wiederherstellung fehlgeschlagen. Bitte versuche es erneut.',
'delete_failed' => 'Backup-Löschen fehlgeschlagen. Bitte versuche es erneut.',
'server_refreshed' => 'Server erfolgreich aktualisiert',
// Common Configuration
'config_not_found' => 'Konfiguration nicht gefunden.',
'footer_config_not_found' => 'Footer-Konfiguration nicht gefunden.',
'navbar_config_not_found' => 'Navbar-Konfiguration nicht gefunden.',
'configuration' => 'Konfiguration: Umgebungs- und Konfigurationsdateien',
// General Settings / UI
'manage_core_settings' => 'Kerneinstellungen deines Systems verwalten',
'app_maintenance' => 'App-Wartung',
'app_version' => 'App-Version',
'current_version' => 'Aktuelle Version:',
// Navbar & Footer
'live_navbar_preview' => 'Live-Navigation Vorschau',
'live_footer_preview' => 'Live-Footer Vorschau',
'interactive_preview' => 'Interaktive Vorschau von',
'before_login' => 'Vor dem Login',
'after_login' => 'Nach dem Login',
// Pages
'collaborative' => 'Kollaborativ',
'administrative' => 'Administrativ',
'custom_pages' => 'Benutzerdefinierte Seiten',
// Maintenance / Backup Management
'note' => 'Hinweis',
'what_backed_up' => 'Was wird gesichert?',
'source_code' => 'Quellcode: Alle Anwendungsdateien und Code',
'database' => 'Datenbank: Vollständiger MySQL-Datenbank-Dump',
'assets' => 'Assets: Hochgeladene Medien und öffentliche Dateien',
'refresh_note' => 'Hinweis: Vor jedem Backup den Server aktualisieren.',
'deleting_backup' => 'Backup wird gelöscht...',
'restoring_backup' => 'Backup wird wiederhergestellt...',
'do_not_close' => 'Bitte schließe dieses Fenster nicht',
'restore_backup_confirmation' => 'Bist du sicher, dass du das Backup ":backup_name" wiederherstellen möchtest?',
'backup_details' => 'Backup-Details',
'permanently_delete_files' => 'Backup-Dateien dauerhaft aus dem Speicher löschen',
'remove_backup_record' => 'Backup-Eintrag aus der Datenbank entfernen',
'cannot_be_undone' => 'Kann nicht rückgängig gemacht oder wiederhergestellt werden',
'replace_current_files' => 'Alle aktuellen Anwendungsdateien ersetzen',
'restore_database_state' => 'Die gesamte Datenbank durch Backup-Daten ersetzen',
'current_data_lost' => 'Alle aktuellen Daten und Dateien gehen verloren',
'action_cannot_undone' => 'Diese Aktion kann nicht rückgängig gemacht werden',
'critical_warning' => 'Wichtiger Hinweis',
'restore_process_time' => 'Der Vorgang kann einige Minuten dauern',
'maintenance_mode_enabled' => 'Website in den Wartungsmodus versetzen',
// Update process (upload UI)
'select_update_file' => 'Update-Datei auswählen',
'drag_drop_update_file' => 'Ziehe deine Update-Datei hierher oder klicke zum Auswählen',
'update_file_requirements' => 'Nur .zip-Dateien erlaubt. Maximale Dateigröße: 500MB',
'no_file_selected' => 'Keine Datei ausgewählt',
'file_selected' => 'Datei ausgewählt',
'browse_files' => 'Dateien durchsuchen',
// Website Settings
'website_information' => 'Website-Informationen',
'contact_information' => 'Kontaktinformationen',
'media_settings' => 'Medien-Einstellungen',
'logo_favicon' => 'Logo & Favicon',
'social_media_links' => 'Social-Media-Links',
// Update Process
'replace_files' => 'Alle Anwendungsdateien ersetzen',
'run_migrations' => 'Datenbank-Migrationen ausführen',
'process_update' => 'Update automatisch durchführen',
// Backup empty state
'no_backups' => 'Keine Backups gefunden',
'no_backups_description' => 'Du hast noch keine Backups erstellt. Erstelle dein erstes Backup, um loszulegen.',
'restore_database' => 'Datenbank auf den Backup-Stand wiederherstellen',
'overwrite_changes' => 'Änderungen seit dem Backup überschreiben',
// Live Class Settings
'zoom_setup_guide' => 'Zoom-Setup-Anleitung',
'setup_instructions' => 'Folge diesen Schritten, um Zoom zu integrieren:',
'create_zoom_app' => 'Erstelle eine Server-to-Server OAuth App im Zoom Marketplace',
'get_credentials' => 'Hole dir Account ID, Client ID und Client Secret',
'configure_scopes' => 'Konfiguriere die erforderlichen Scopes für deine App',
];

53
lang/de/table.php Normal file
View File

@ -0,0 +1,53 @@
<?php
return [
'type' => 'Typ',
'csv' => 'CSV',
'resume' => 'Lebenslauf',
'view_resume' => 'Lebenslauf ansehen',
'img_placeholder' => 'Bild',
'no_data' => 'Keine Daten verfügbar',
'go_to_page' => 'Gehe zu Seite:',
'previous' => 'Zurück',
'showing_results' => 'Ergebnisse werden angezeigt',
'total_results' => 'Gesamtergebnisse',
'delete_instructor_warning' => 'Nach dem Löschen des Dozenten wird der Admin als neuer Dozent für alle Kurse dieses Dozenten zugewiesen.',
'delete_course_warning' => 'Nach dem Löschen des Kurses werden alle zugehörigen Daten (z. B. Kursabschnitte, Lektionen, Quizze, Einschreibungen usw.) automatisch gelöscht.',
'name' => 'Name',
'email' => 'E-Mail',
'role' => 'Rolle',
'slug' => 'Slug',
'title' => 'Titel',
'use_case' => 'Anwendungsfall',
'sections' => 'Abschnitte',
'creator' => 'Ersteller',
'enrolled_course' => 'Eingeschriebener Kurs',
'enrolled_date' => 'Einschreibedatum',
'expiry_date' => 'Ablaufdatum',
'payout_amount' => 'Auszahlungsbetrag',
'payout_method' => 'Auszahlungsmethode',
'processed_date' => 'Verarbeitungsdatum',
'payout_date' => 'Auszahlungsdatum',
'enrollments' => 'Einschreibungen',
'course_title' => 'Kurstitel',
'number_of_course' => 'Anzahl der Kurse',
'category_name' => 'Kategoriename',
'category_child' => 'Unterkategorie',
'meta_description' => 'Meta-Beschreibung',
'meta_keywords' => 'Meta-Keywords',
'action' => 'Aktion',
'status' => 'Status',
'custom_pages' => 'Benutzerdefinierte Seiten',
'add_custom_page' => 'Benutzerdefinierte Seite hinzufügen',
'pay' => 'Bezahlen',
'print' => 'Drucken',
'select' => 'Auswählen',
'selected' => 'Ausgewählt',
'edit_page' => 'Seite bearbeiten',
'copy_url' => 'URL kopieren',
'preview_page' => 'Seite ansehen',
'lifetime_access' => 'Lebenslanger Zugriff',
'url_copied' => 'URL in die Zwischenablage kopiert',
'best_single_instructor' => 'Am besten für einen einzelnen Dozenten',
'best_multiple_instructors' => 'Am besten für mehrere Dozenten',
];

175
lang/de/validation.php Normal file
View File

@ -0,0 +1,175 @@
<?php
return [
'accepted' => 'Das Feld :attribute muss akzeptiert werden.',
'accepted_if' => 'Das Feld :attribute muss akzeptiert werden, wenn :other :value ist.',
'active_url' => 'Das Feld :attribute muss eine gültige URL sein.',
'after' => 'Das Feld :attribute muss ein Datum nach dem :date sein.',
'after_or_equal' => 'Das Feld :attribute muss ein Datum nach oder gleich :date sein.',
'alpha' => 'Das Feld :attribute darf nur Buchstaben enthalten.',
'alpha_dash' => 'Das Feld :attribute darf nur Buchstaben, Zahlen, Bindestriche und Unterstriche enthalten.',
'alpha_num' => 'Das Feld :attribute darf nur Buchstaben und Zahlen enthalten.',
'any_of' => 'Das Feld :attribute ist ungültig.',
'array' => 'Das Feld :attribute muss ein Array sein.',
'ascii' => 'Das Feld :attribute darf nur Single-Byte alphanumerische Zeichen und Symbole enthalten.',
'before' => 'Das Feld :attribute muss ein Datum vor dem :date sein.',
'before_or_equal' => 'Das Feld :attribute muss ein Datum vor oder gleich :date sein.',
'between' =>
array (
'array' => 'Das Feld :attribute muss zwischen :min und :max Elemente enthalten.',
'file' => 'Das Feld :attribute muss zwischen :min und :max Kilobyte groß sein.',
'numeric' => 'Das Feld :attribute muss zwischen :min und :max liegen.',
'string' => 'Das Feld :attribute muss zwischen :min und :max Zeichen enthalten.',
),
'boolean' => 'Das Feld :attribute muss wahr oder falsch sein.',
'can' => 'Das Feld :attribute enthält einen nicht autorisierten Wert.',
'confirmed' => 'Die Bestätigung von :attribute stimmt nicht überein.',
'contains' => 'Dem Feld :attribute fehlt ein erforderlicher Wert.',
'current_password' => 'Das Passwort ist falsch.',
'date' => 'Das Feld :attribute muss ein gültiges Datum sein.',
'date_equals' => 'Das Feld :attribute muss ein Datum gleich :date sein.',
'date_format' => 'Das Feld :attribute muss dem Format :format entsprechen.',
'decimal' => 'Das Feld :attribute muss :decimal Dezimalstellen haben.',
'declined' => 'Das Feld :attribute muss abgelehnt werden.',
'declined_if' => 'Das Feld :attribute muss abgelehnt werden, wenn :other :value ist.',
'different' => 'Die Felder :attribute und :other müssen unterschiedlich sein.',
'digits' => 'Das Feld :attribute muss :digits Ziffern enthalten.',
'digits_between' => 'Das Feld :attribute muss zwischen :min und :max Ziffern enthalten.',
'dimensions' => 'Das Feld :attribute hat ungültige Bildabmessungen.',
'distinct' => 'Das Feld :attribute enthält einen doppelten Wert.',
'doesnt_contain' => 'Das Feld :attribute darf keinen der folgenden Werte enthalten: :values.',
'doesnt_end_with' => 'Das Feld :attribute darf nicht mit einem der folgenden Werte enden: :values.',
'doesnt_start_with' => 'Das Feld :attribute darf nicht mit einem der folgenden Werte beginnen: :values.',
'email' => 'Das Feld :attribute muss eine gültige E-Mail-Adresse sein.',
'ends_with' => 'Das Feld :attribute muss mit einem der folgenden Werte enden: :values.',
'enum' => 'Der ausgewählte Wert für :attribute ist ungültig.',
'exists' => 'Der ausgewählte Wert für :attribute ist ungültig.',
'extensions' => 'Das Feld :attribute muss eine der folgenden Dateiendungen haben: :values.',
'file' => 'Das Feld :attribute muss eine Datei sein.',
'filled' => 'Das Feld :attribute muss einen Wert enthalten.',
'gt' =>
array (
'array' => 'Das Feld :attribute muss mehr als :value Elemente enthalten.',
'file' => 'Das Feld :attribute muss größer als :value Kilobyte sein.',
'numeric' => 'Das Feld :attribute muss größer als :value sein.',
'string' => 'Das Feld :attribute muss mehr als :value Zeichen enthalten.',
),
'gte' =>
array (
'array' => 'Das Feld :attribute muss :value oder mehr Elemente enthalten.',
'file' => 'Das Feld :attribute muss größer oder gleich :value Kilobyte sein.',
'numeric' => 'Das Feld :attribute muss größer oder gleich :value sein.',
'string' => 'Das Feld :attribute muss mindestens :value Zeichen enthalten.',
),
'hex_color' => 'Das Feld :attribute muss eine gültige hexadezimale Farbe sein.',
'image' => 'Das Feld :attribute muss ein Bild sein.',
'in' => 'Der ausgewählte Wert für :attribute ist ungültig.',
'in_array' => 'Das Feld :attribute muss in :other vorhanden sein.',
'in_array_keys' => 'Das Feld :attribute muss mindestens einen der folgenden Schlüssel enthalten: :values.',
'integer' => 'Das Feld :attribute muss eine ganze Zahl sein.',
'ip' => 'Das Feld :attribute muss eine gültige IP-Adresse sein.',
'ipv4' => 'Das Feld :attribute muss eine gültige IPv4-Adresse sein.',
'ipv6' => 'Das Feld :attribute muss eine gültige IPv6-Adresse sein.',
'json' => 'Das Feld :attribute muss eine gültige JSON-Zeichenkette sein.',
'list' => 'Das Feld :attribute muss eine Liste sein.',
'lowercase' => 'Das Feld :attribute muss klein geschrieben sein.',
'lt' =>
array (
'array' => 'Das Feld :attribute muss weniger als :value Elemente enthalten.',
'file' => 'Das Feld :attribute muss kleiner als :value Kilobyte sein.',
'numeric' => 'Das Feld :attribute muss kleiner als :value sein.',
'string' => 'Das Feld :attribute muss weniger als :value Zeichen enthalten.',
),
'lte' =>
array (
'array' => 'Das Feld :attribute darf nicht mehr als :value Elemente enthalten.',
'file' => 'Das Feld :attribute muss kleiner oder gleich :value Kilobyte sein.',
'numeric' => 'Das Feld :attribute muss kleiner oder gleich :value sein.',
'string' => 'Das Feld :attribute darf nicht mehr als :value Zeichen enthalten.',
),
'mac_address' => 'Das Feld :attribute muss eine gültige MAC-Adresse sein.',
'max' =>
array (
'array' => 'Das Feld :attribute darf nicht mehr als :max Elemente enthalten.',
'file' => 'Das Feld :attribute darf nicht größer als :max Kilobyte sein.',
'numeric' => 'Das Feld :attribute darf nicht größer als :max sein.',
'string' => 'Das Feld :attribute darf nicht mehr als :max Zeichen enthalten.',
),
'max_digits' => 'Das Feld :attribute darf nicht mehr als :max Ziffern enthalten.',
'mimes' => 'Das Feld :attribute muss eine Datei vom Typ: :values sein.',
'mimetypes' => 'Das Feld :attribute muss eine Datei vom Typ: :values sein.',
'min' =>
array (
'array' => 'Das Feld :attribute muss mindestens :min Elemente enthalten.',
'file' => 'Das Feld :attribute muss mindestens :min Kilobyte groß sein.',
'numeric' => 'Das Feld :attribute muss mindestens :min sein.',
'string' => 'Das Feld :attribute muss mindestens :min Zeichen enthalten.',
),
'min_digits' => 'Das Feld :attribute muss mindestens :min Ziffern enthalten.',
'missing' => 'Das Feld :attribute muss fehlen.',
'missing_if' => 'Das Feld :attribute muss fehlen, wenn :other :value ist.',
'missing_unless' => 'Das Feld :attribute muss fehlen, sofern :other nicht :value ist.',
'missing_with' => 'Das Feld :attribute muss fehlen, wenn :values vorhanden ist.',
'missing_with_all' => 'Das Feld :attribute muss fehlen, wenn :values vorhanden sind.',
'multiple_of' => 'Das Feld :attribute muss ein Vielfaches von :value sein.',
'not_in' => 'Der ausgewählte Wert für :attribute ist ungültig.',
'not_regex' => 'Das Format des Feldes :attribute ist ungültig.',
'numeric' => 'Das Feld :attribute muss eine Zahl sein.',
'password' =>
array (
'letters' => 'Das Feld :attribute muss mindestens einen Buchstaben enthalten.',
'mixed' => 'Das Feld :attribute muss mindestens einen Groß- und einen Kleinbuchstaben enthalten.',
'numbers' => 'Das Feld :attribute muss mindestens eine Zahl enthalten.',
'symbols' => 'Das Feld :attribute muss mindestens ein Symbol enthalten.',
'uncompromised' => 'Das angegebene :attribute ist in einem Datenleck aufgetaucht. Bitte wähle ein anderes :attribute.',
),
'present' => 'Das Feld :attribute muss vorhanden sein.',
'present_if' => 'Das Feld :attribute muss vorhanden sein, wenn :other :value ist.',
'present_unless' => 'Das Feld :attribute muss vorhanden sein, sofern :other nicht :value ist.',
'present_with' => 'Das Feld :attribute muss vorhanden sein, wenn :values vorhanden ist.',
'present_with_all' => 'Das Feld :attribute muss vorhanden sein, wenn :values vorhanden sind.',
'prohibited' => 'Das Feld :attribute ist unzulässig.',
'prohibited_if' => 'Das Feld :attribute ist unzulässig, wenn :other :value ist.',
'prohibited_if_accepted' => 'Das Feld :attribute ist unzulässig, wenn :other akzeptiert ist.',
'prohibited_if_declined' => 'Das Feld :attribute ist unzulässig, wenn :other abgelehnt ist.',
'prohibited_unless' => 'Das Feld :attribute ist unzulässig, sofern :other nicht in :values ist.',
'prohibits' => 'Das Feld :attribute verhindert, dass :other vorhanden ist.',
'regex' => 'Das Format des Feldes :attribute ist ungültig.',
'required' => 'Das Feld :attribute ist erforderlich.',
'required_array_keys' => 'Das Feld :attribute muss Einträge enthalten für: :values.',
'required_if' => 'Das Feld :attribute ist erforderlich, wenn :other :value ist.',
'required_if_accepted' => 'Das Feld :attribute ist erforderlich, wenn :other akzeptiert ist.',
'required_if_declined' => 'Das Feld :attribute ist erforderlich, wenn :other abgelehnt ist.',
'required_unless' => 'Das Feld :attribute ist erforderlich, sofern :other nicht in :values ist.',
'required_with' => 'Das Feld :attribute ist erforderlich, wenn :values vorhanden ist.',
'required_with_all' => 'Das Feld :attribute ist erforderlich, wenn :values vorhanden sind.',
'required_without' => 'Das Feld :attribute ist erforderlich, wenn :values nicht vorhanden ist.',
'required_without_all' => 'Das Feld :attribute ist erforderlich, wenn keines von :values vorhanden ist.',
'same' => 'Das Feld :attribute muss mit :other übereinstimmen.',
'size' =>
array (
'array' => 'Das Feld :attribute muss :size Elemente enthalten.',
'file' => 'Das Feld :attribute muss :size Kilobyte groß sein.',
'numeric' => 'Das Feld :attribute muss :size sein.',
'string' => 'Das Feld :attribute muss :size Zeichen enthalten.',
),
'starts_with' => 'Das Feld :attribute muss mit einem der folgenden Werte beginnen: :values.',
'string' => 'Das Feld :attribute muss eine Zeichenkette sein.',
'timezone' => 'Das Feld :attribute muss eine gültige Zeitzone sein.',
'unique' => ':attribute ist bereits vergeben.',
'uploaded' => ':attribute konnte nicht hochgeladen werden.',
'uppercase' => 'Das Feld :attribute muss groß geschrieben sein.',
'url' => 'Das Feld :attribute muss eine gültige URL sein.',
'ulid' => 'Das Feld :attribute muss eine gültige ULID sein.',
'uuid' => 'Das Feld :attribute muss eine gültige UUID sein.',
'custom' =>
array (
'attribute-name' =>
array (
'rule-name' => 'custom-message',
),
),
'attributes' =>
[
],
];

View File

@ -60,6 +60,7 @@ return [
'refresh_server' => 'Refresh Server',
'backup_now' => 'Backup Now',
'update_application' => 'Update Application',
'set_default' => 'Set Default',
// ==================================================
// 03. Action Buttons
@ -162,14 +163,17 @@ return [
'main_menu' => 'Main Menu',
'categories' => 'Categories',
'courses' => 'Courses',
'exams' => 'Exams',
'enrollments' => 'Enrollments',
'instructors' => 'Instructors',
'payout_report' => 'Payout Report',
'payment_report' => 'Payment Report',
'payouts' => 'Payouts',
'job_circulars' => 'Job Circulars',
'blogs' => 'Blogs',
'newsletters' => 'Newsletters',
'all_users' => 'All Users',
'certificates' => 'Certificates',
'settings' => 'Settings',
'my_courses' => 'My Courses',
'wishlist' => 'Wishlist',
@ -179,6 +183,14 @@ return [
'profile_update' => 'Profile Update',
'manage_courses' => 'Manage Courses',
'create_course' => 'Create Course',
'course_coupons' => 'Course Coupons',
'manage_exams' => 'Manage Exams',
'create_exam' => 'Create Exam',
'exam_coupons' => 'Exam Coupons',
'course_enrollments' => 'Course Enrollments',
'exam_enrollments' => 'Exam Enrollments',
'online_payments' => 'Online Payments',
'offline_payments' => 'Offline Payments',
'manage_enrollments' => 'Manage Enrollments',
'add_new_enrollment' => 'Add New Enrollment',
'add_new_instructor' => 'Add New Instructor',
@ -200,6 +212,7 @@ return [
'smtp' => 'SMTP',
'auth0' => 'Auth0',
'maintenance' => 'Maintenance',
'marksheet' => 'Marksheet',
// ==================================================
// 08. Sub Navigation Buttons

View File

@ -25,6 +25,9 @@ return [
'language_settings' => 'Language Settings',
'translation_settings' => 'Translation Settings',
'add_language' => 'Add Language',
'translation_scope_information' => 'Translation Scope Information',
'translation_scope_dashboard' => 'Translations will be applied to dashboard interfaces (admin, instructor, student).',
'translation_scope_public_pages' => 'Public pages are not affected by these translations as they are fully customizable through the page editor.',
// Common Settings
'account_settings' => 'Account Settings',

View File

@ -18,6 +18,7 @@ const CourseCard1 = ({ course, viewType = 'grid', className, wishlists }: Props)
const { user } = props.auth;
const { translate } = props;
const { button, frontend, common } = translate;
const showWishlist = props.system.fields?.show_student_wishlist !== false;
const isWishlisted = wishlists?.find((wishlist) => wishlist.course_id === course.id);
const currency = systemCurrency(props.system.fields['selling_currency']);
@ -55,7 +56,7 @@ const CourseCard1 = ({ course, viewType = 'grid', className, wishlists }: Props)
</Link>
</div>
{wishlists && (
{wishlists && showWishlist && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger className="absolute top-3 right-3 z-10 opacity-0 group-hover:opacity-100">

View File

@ -17,6 +17,7 @@ const CourseCard2 = ({ course, className, wishlists }: Props) => {
const { props } = usePage<SharedData>();
const { user } = props.auth;
const { button, common, frontend } = props.translate;
const showWishlist = props.system.fields?.show_student_wishlist !== false;
const isWishlisted = wishlists?.find((wishlist) => wishlist.course_id === course.id);
const currency = systemCurrency(props.system.fields['selling_currency']);
@ -52,7 +53,7 @@ const CourseCard2 = ({ course, className, wishlists }: Props) => {
</div>
</Link>
{wishlists && (
{wishlists && showWishlist && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger className="absolute top-2 right-2 z-10">

View File

@ -17,6 +17,7 @@ const CourseCard6 = ({ course, type = 'grid', className, wishlists }: Props) =>
const { props } = usePage<SharedData>();
const { user } = props.auth;
const { button, common, frontend } = props.translate;
const showWishlist = props.system.fields?.show_student_wishlist !== false;
const isWishlisted = wishlists?.find((wishlist) => wishlist.course_id === course.id);
const currency = systemCurrency(props.system.fields['selling_currency']);
@ -54,7 +55,7 @@ const CourseCard6 = ({ course, type = 'grid', className, wishlists }: Props) =>
</Link>
</div>
{wishlists && (
{wishlists && showWishlist && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger className="absolute top-2 right-2 z-10">

View File

@ -62,7 +62,7 @@ const ExamCard7 = ({ exam, attempts, bestAttempt, className }: Props) => {
<p className="text-muted-foreground flex items-center justify-between text-sm font-medium">
<span>Attempts</span>
<span>
{totalAttempts} / {exam.max_attempts}
{exam.max_attempts === 0 ? `${totalAttempts} / Unlimited` : `${totalAttempts} / ${exam.max_attempts}`}
</span>
</p>
<Progress value={progressPercentage} className="h-1.5" />
@ -87,7 +87,7 @@ const ExamCard7 = ({ exam, attempts, bestAttempt, className }: Props) => {
)}
</div>
{totalAttempts < exam.max_attempts && (
{(exam.max_attempts === 0 || totalAttempts < exam.max_attempts) && (
<ButtonGradientPrimary
asChild
shadow={false}

View File

@ -20,7 +20,7 @@ const CertificateGenerator = () => {
const handleGenerateCertificate = async () => {
if (!studentName || !courseName || !completionDate) {
toast.error('Please fill in all required fields.');
toast.error('Bitte fülle alle Pflichtfelder aus.');
return;
}
@ -29,7 +29,7 @@ const CertificateGenerator = () => {
// Simulate certificate generation
setTimeout(() => {
setIsGenerating(false);
toast.success('Your course completion certificate has been created successfully.');
toast.success('Dein Kursabschlusszertifikat wurde erfolgreich erstellt.');
}, 2000);
};
@ -73,7 +73,7 @@ const CertificateGenerator = () => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Your PNG certificate has been saved to your downloads folder.');
toast.success('Dein PNG-Zertifikat wurde in deinem Download-Ordner gespeichert.');
}, 'image/png');
};
@ -107,7 +107,7 @@ const CertificateGenerator = () => {
// Save the PDF
pdf.save(`${studentName}_${courseName}_Certificate.pdf`);
toast.success('Your PDF certificate has been saved to your downloads folder.');
toast.success('Dein PDF-Zertifikat wurde in deinem Download-Ordner gespeichert.');
};
const drawCertificate = (ctx: CanvasRenderingContext2D, dimensions: { width: number; height: number }) => {
@ -135,7 +135,7 @@ const CertificateGenerator = () => {
// Title
ctx.font = 'bold 42px serif';
ctx.fillText('Certificate of Completion', dimensions.width / 2, 120);
ctx.fillText('Abschlusszertifikat', dimensions.width / 2, 120);
// Decorative line under title
ctx.strokeStyle = '#f59e0b';
@ -148,7 +148,7 @@ const CertificateGenerator = () => {
// "This is to certify that"
ctx.font = '22px serif';
ctx.fillStyle = '#4b5563';
ctx.fillText('This is to certify that', dimensions.width / 2, 190);
ctx.fillText('Hiermit wird bescheinigt, dass', dimensions.width / 2, 190);
// Student name with underline
ctx.font = 'bold 36px serif';
@ -167,7 +167,7 @@ const CertificateGenerator = () => {
// "has successfully completed the course"
ctx.font = '22px serif';
ctx.fillStyle = '#4b5563';
ctx.fillText('has successfully completed the course', dimensions.width / 2, 320);
ctx.fillText('den Kurs erfolgreich abgeschlossen hat', dimensions.width / 2, 320);
// Course name
ctx.font = 'bold 28px serif';
@ -177,7 +177,7 @@ const CertificateGenerator = () => {
// Completion date
ctx.font = '18px serif';
ctx.fillStyle = '#6b7280';
ctx.fillText(`Completed on: ${completionDate}`, dimensions.width / 2, 430);
ctx.fillText(`Abgeschlossen am: ${completionDate}`, dimensions.width / 2, 430);
// Footer
ctx.font = '16px serif';
@ -190,8 +190,8 @@ const CertificateGenerator = () => {
return (
<div className="space-y-8">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold text-gray-800">Course Certificate Generator</h1>
<p className="text-muted-foreground text-lg">Generate your official course completion certificate</p>
<h1 className="mb-4 text-4xl font-bold text-gray-800">Zertifikatsgenerator</h1>
<p className="text-muted-foreground text-lg">Erstelle dein offizielles Kursabschlusszertifikat</p>
</div>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-2">
@ -200,23 +200,23 @@ const CertificateGenerator = () => {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Award className="h-5 w-5 text-amber-600" />
Certificate Details
Zertifikatsdetails
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="studentName">Student Name *</Label>
<Label htmlFor="studentName">Name des Teilnehmers *</Label>
<Input
id="studentName"
value={studentName}
onChange={(e) => setStudentName(e.target.value)}
placeholder="Enter your full name"
placeholder="Gib deinen vollständigen Namen ein"
/>
</div>
<div className="space-y-2">
<Label htmlFor="courseName">Course Name *</Label>
<Input id="courseName" value={courseName} onChange={(e) => setCourseName(e.target.value)} placeholder="Enter the course name" />
<Label htmlFor="courseName">Kursname *</Label>
<Input id="courseName" value={courseName} onChange={(e) => setCourseName(e.target.value)} placeholder="Gib den Kursnamen ein" />
</div>
<div className="space-y-2">
@ -232,33 +232,33 @@ const CertificateGenerator = () => {
<div className="space-y-4">
<div className="space-y-2">
<Label>Certificate Size</Label>
<Label>Zertifikatsgröße</Label>
<Select value={certificateSize} onValueChange={setCertificateSize}>
<SelectTrigger>
<SelectValue placeholder="Select certificate size" />
<SelectValue placeholder="Wähle Zertifikatsgröße" />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">Standard (800x600)</SelectItem>
<SelectItem value="a4">A4 Landscape (842x595)</SelectItem>
<SelectItem value="standard">Standard (800×600)</SelectItem>
<SelectItem value="a4">A4 Querformat (842×595)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<Label>Download Format</Label>
<Label>Download-Format</Label>
<RadioGroup value={downloadFormat} onValueChange={setDownloadFormat} className="flex space-x-6">
<div className="flex items-center space-x-2">
<RadioGroupItem className="cursor-pointer" value="png" id="png" />
<Label htmlFor="png" className="flex cursor-pointer items-center gap-2">
<FileImage className="h-4 w-4" />
PNG Image
PNG-Bild
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem className="cursor-pointer" value="pdf" id="pdf" />
<Label htmlFor="pdf" className="flex cursor-pointer items-center gap-2">
<FileText className="h-4 w-4" />
PDF Document
PDF-Dokument
</Label>
</div>
</RadioGroup>
@ -269,12 +269,12 @@ const CertificateGenerator = () => {
{isGenerating ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-b-2 border-white" />
Generating Certificate...
Zertifikat wird erstellt...
</>
) : (
<>
<Award className="mr-2 h-4 w-4" />
Generate Certificate
Zertifikat erstellen
</>
)}
</Button>
@ -284,7 +284,7 @@ const CertificateGenerator = () => {
{/* Preview Section */}
<Card>
<CardHeader>
<CardTitle>Certificate Preview</CardTitle>
<CardTitle>Zertifikatsvorschau</CardTitle>
</CardHeader>
<CardContent>
<div
@ -297,26 +297,26 @@ const CertificateGenerator = () => {
<div className="relative z-10">
<div className="mb-6">
<Award className="mx-auto mb-3 h-12 w-12 text-amber-600" />
<h2 className="mb-2 font-serif text-2xl font-bold text-gray-800">Certificate of Completion</h2>
<h2 className="mb-2 font-serif text-2xl font-bold text-gray-800">Abschlusszertifikat</h2>
<div className="mx-auto h-0.5 w-32 bg-amber-400"></div>
</div>
<div className="space-y-4 text-gray-700">
<p className="font-serif text-lg">This is to certify that</p>
<p className="font-serif text-lg">Hiermit wird bescheinigt, dass</p>
<div className="relative">
<p className="mx-8 pb-2 font-serif text-2xl font-bold text-indigo-800">{studentName || 'Student Name'}</p>
<div className="absolute bottom-0 left-1/2 h-0.5 w-48 -translate-x-1/2 transform bg-amber-400"></div>
</div>
<p className="font-serif text-lg">has successfully completed the course</p>
<p className="font-serif text-xl font-semibold text-indigo-700">{courseName || 'Course Name'}</p>
<p className="font-serif text-lg">den Kurs erfolgreich abgeschlossen hat</p>
<p className="font-serif text-xl font-semibold text-indigo-700">{courseName || 'Kursname'}</p>
<div className="mt-6 flex items-center justify-center gap-2">
<Calendar className="text-muted-foreground h-4 w-4" />
<p className="text-muted-foreground font-serif text-sm">Completed on: {completionDate || 'Date'}</p>
<p className="text-muted-foreground font-serif text-sm">Abgeschlossen am: {completionDate || 'Datum'}</p>
</div>
</div>
<div className="mt-6 border-t border-amber-400 pt-4">
<p className="font-serif text-sm text-gray-500">Authorized Certificate of Achievement</p>
<p className="font-serif text-sm text-gray-500">Autorisierte Leistungsurkunde</p>
</div>
</div>
</div>
@ -324,7 +324,7 @@ const CertificateGenerator = () => {
{studentName && courseName && completionDate && (
<Button variant="outline" className="mt-4 w-full" onClick={handleDownloadCertificate}>
<Download className="mr-2 h-4 w-4" />
Download as {downloadFormat.toUpperCase()}
Herunterladen als {downloadFormat.toUpperCase()}
</Button>
)}
</CardContent>

View File

@ -49,7 +49,7 @@ const Certificate = ({ course, watchHistory }: { course: Course; watchHistory: W
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${studentName}_${courseName}_Certificate.png`;
a.download = `${studentName}_${courseName}_Zertifikat.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@ -86,7 +86,7 @@ const Certificate = ({ course, watchHistory }: { course: Course; watchHistory: W
pdf.addImage(imgData, 'PNG', 0, 0, dimensions.width, dimensions.height);
// Save the PDF
pdf.save(`${studentName}_${courseName}_Certificate.pdf`);
pdf.save(`${studentName}_${courseName}_Zertifikat.pdf`);
toast.success(frontend.pdf_certificate_saved);
};
@ -253,21 +253,21 @@ const Certificate = ({ course, watchHistory }: { course: Course; watchHistory: W
<RadioGroupItem className="cursor-pointer" value="png" id="png" />
<Label htmlFor="png" className="flex cursor-pointer items-center gap-2">
<FileImage className="h-4 w-4" />
PNG Image
PNG-Bild
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem className="cursor-pointer" value="pdf" id="pdf" />
<Label htmlFor="pdf" className="flex cursor-pointer items-center gap-2">
<FileText className="h-4 w-4" />
PDF Document
PDF-Dokument
</Label>
</div>
</RadioGroup>
<Button variant="outline" className="w-full" onClick={handleDownloadCertificate}>
<Download className="mr-2 h-4 w-4" />
Download as {downloadFormat.toUpperCase()}
Herunterladen als {downloadFormat.toUpperCase()}
</Button>
</div>
</Card>

View File

@ -68,13 +68,13 @@ const DynamicCertificate = ({ template, courseName, studentName, completionDate
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${studentName}_${courseName}_Certificate.png`;
a.download = `${studentName}_${courseName}_Zertifikat.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Certificate saved as PNG!');
toast.success('Zertifikat als PNG gespeichert!');
}, 'image/png');
};
@ -111,7 +111,7 @@ const DynamicCertificate = ({ template, courseName, studentName, completionDate
pdf.addImage(imgData, 'PNG', 0, 0, dimensions.width, dimensions.height);
pdf.save(`${studentName}_${courseName}_Certificate.pdf`);
toast.success('Certificate saved as PDF!');
toast.success('Zertifikat als PDF gespeichert!');
};
const wrapText = (ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number, lineHeight: number) => {
@ -228,7 +228,7 @@ const DynamicCertificate = ({ template, courseName, studentName, completionDate
// Completion date
ctx.font = `18px ${template_data.fontFamily}`;
ctx.fillStyle = template_data.secondaryColor;
ctx.fillText(`Completed on: ${completionDate}`, dimensions.width / 2, currentY);
ctx.fillText(`Abgeschlossen am: ${completionDate}`, dimensions.width / 2, currentY);
currentY += 60;
// Footer
@ -352,7 +352,7 @@ const DynamicCertificate = ({ template, courseName, studentName, completionDate
color: template_data.secondaryColor,
}}
>
Completed on: {completionDate}
Abgeschlossen am: {completionDate}
</p>
</div>
</div>
@ -382,21 +382,21 @@ const DynamicCertificate = ({ template, courseName, studentName, completionDate
<RadioGroupItem className="cursor-pointer" value="png" id="png" />
<Label htmlFor="png" className="flex cursor-pointer items-center gap-2">
<FileImage className="h-4 w-4" />
PNG Image
PNG-Bild
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem className="cursor-pointer" value="pdf" id="pdf" />
<Label htmlFor="pdf" className="flex cursor-pointer items-center gap-2">
<FileText className="h-4 w-4" />
PDF Document
PDF-Dokument
</Label>
</div>
</RadioGroup>
<Button variant="outline" className="w-full" onClick={handleDownloadCertificate}>
<Download className="mr-2 h-4 w-4" />
Download as {downloadFormat.toUpperCase()}
Herunterladen als {downloadFormat.toUpperCase()}
</Button>
</div>
</Card>

View File

@ -69,13 +69,13 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${studentName}_${courseName}_Marksheet.png`;
a.download = `${studentName}_${courseName}_Notenblatt.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('Marksheet saved as PNG!');
toast.success('Notenblatt als PNG gespeichert!');
}, 'image/png');
};
@ -110,9 +110,9 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
const imgData = canvas.toDataURL('image/png');
pdf.addImage(imgData, 'PNG', 0, 0, dimensions.width, dimensions.height);
pdf.save(`${studentName}_${courseName}_Marksheet.pdf`);
pdf.save(`${studentName}_${courseName}_Notenblatt.pdf`);
toast.success('Marksheet saved as PDF!');
toast.success('Notenblatt als PDF gespeichert!');
};
const drawMarksheet = async (
@ -174,7 +174,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
// Column 1 - Student Name
ctx.font = `16px ${template_data.fontFamily}`;
ctx.fillStyle = template_data.secondaryColor;
ctx.fillText('Student Name', col1X, currentY);
ctx.fillText('Name des Studierenden', col1X, currentY);
ctx.font = `bold 20px ${template_data.fontFamily}`;
ctx.fillStyle = template_data.primaryColor;
@ -183,7 +183,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
// Column 2 - Course
ctx.font = `16px ${template_data.fontFamily}`;
ctx.fillStyle = template_data.secondaryColor;
ctx.fillText('Course', col2X, currentY);
ctx.fillText('Kurs', col2X, currentY);
ctx.font = `bold 20px ${template_data.fontFamily}`;
ctx.fillStyle = template_data.primaryColor;
@ -215,7 +215,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
// Column 1 - Completion Date
ctx.font = `16px ${template_data.fontFamily}`;
ctx.fillStyle = template_data.secondaryColor;
ctx.fillText('Completion Date', col1X, currentY);
ctx.fillText('Abschlussdatum', col1X, currentY);
ctx.font = `18px ${template_data.fontFamily}`;
ctx.fillStyle = template_data.primaryColor;
@ -224,7 +224,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
// Column 2 - Overall Grade
ctx.font = `16px ${template_data.fontFamily}`;
ctx.fillStyle = template_data.secondaryColor;
ctx.fillText('Overall Grade', col2X, currentY);
ctx.fillText('Gesamtnote', col2X, currentY);
ctx.font = `bold 20px ${template_data.fontFamily}`;
ctx.fillStyle = template_data.primaryColor;
@ -236,7 +236,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
ctx.textAlign = 'left';
ctx.font = `bold 22px ${template_data.fontFamily}`;
ctx.fillStyle = template_data.primaryColor;
ctx.fillText('Exam Type', leftMargin, currentY);
ctx.fillText('Prüfungsart', leftMargin, currentY);
currentY += 35;
// Table Header Background
@ -247,9 +247,9 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
// Table Headers
ctx.font = `bold 18px ${template_data.fontFamily}`;
ctx.fillStyle = template_data.primaryColor;
ctx.fillText('Exam Type', leftMargin + 15, currentY + 28);
ctx.fillText('Prüfungsart', leftMargin + 15, currentY + 28);
ctx.textAlign = 'right';
ctx.fillText('Total Marks', rightMargin - 15, currentY + 28);
ctx.fillText('Gesamtpunkte', rightMargin - 15, currentY + 28);
// Table Border
ctx.strokeStyle = template_data.borderColor;
@ -264,7 +264,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
ctx.textAlign = 'left';
ctx.font = `18px ${template_data.fontFamily}`;
ctx.fillStyle = template_data.secondaryColor;
ctx.fillText('Assignment', leftMargin + 15, currentY + 28);
ctx.fillText('Aufgabe', leftMargin + 15, currentY + 28);
ctx.textAlign = 'right';
ctx.font = `bold 18px ${template_data.fontFamily}`;
@ -378,7 +378,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
color: template_data.secondaryColor,
}}
>
Student Name
Name des Studierenden
</p>
<p
className="text-lg font-semibold"
@ -396,7 +396,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
color: template_data.secondaryColor,
}}
>
Course
Kurs
</p>
<p
className="text-lg font-semibold"
@ -414,7 +414,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
color: template_data.secondaryColor,
}}
>
Completion Date
Abschlussdatum
</p>
<div className="flex items-center gap-2">
<Calendar
@ -440,7 +440,7 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
color: template_data.secondaryColor,
}}
>
Overall Grade
Gesamtnote
</p>
<p
className="text-2xl font-bold"
@ -569,21 +569,21 @@ const DynamicMarksheet = ({ template, courseName, studentName, completionDate, s
<RadioGroupItem className="cursor-pointer" value="png" id="marksheet-png" />
<Label htmlFor="marksheet-png" className="flex cursor-pointer items-center gap-2">
<FileImage className="h-4 w-4" />
PNG Image
PNG-Bild
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem className="cursor-pointer" value="pdf" id="marksheet-pdf" />
<Label htmlFor="marksheet-pdf" className="flex cursor-pointer items-center gap-2">
<FileText className="h-4 w-4" />
PDF Document
PDF-Dokument
</Label>
</div>
</RadioGroup>
<Button variant="outline" className="w-full" onClick={handleDownloadMarksheet}>
<Download className="mr-2 h-4 w-4" />
Download as {downloadFormat.toUpperCase()}
Herunterladen als {downloadFormat.toUpperCase()}
</Button>
</div>
</Card>

View File

@ -20,6 +20,7 @@ const ExamCard1 = ({ exam, variant = 'default', viewType = 'grid', onAddToCart,
const { props } = usePage<SharedData>();
const { translate } = props;
const { common } = translate;
const showWishlist = props.system.fields?.show_student_wishlist !== false;
const isCompact = variant === 'compact';
const examUrl = route('exams.details', { slug: exam.slug, id: exam.id });
@ -97,7 +98,7 @@ const ExamCard1 = ({ exam, variant = 'default', viewType = 'grid', onAddToCart,
</div>
<div className="flex items-center gap-2">
{onAddToWishlist && (
{showWishlist && onAddToWishlist && (
<Button
variant="outline"
size="icon"

View File

@ -24,9 +24,14 @@ const Notification = () => {
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between border-b px-4 py-2">
<div className="flex flex-wrap items-center justify-between gap-2 border-b px-4 py-2">
<h4 className="font-semibold">{dashboard.notifications}</h4>
<Button variant="ghost" size="sm" className="px-2 text-xs" onClick={() => router.put(route('notifications.mark-all-as-read'))}>
<Button
variant="ghost"
size="sm"
className="h-auto px-2 text-xs leading-snug whitespace-normal"
onClick={() => router.put(route('notifications.mark-all-as-read'))}
>
{button.mark_all_as_read}
</Button>
</div>

View File

@ -9,6 +9,7 @@ const ProfileToggle = () => {
const { props } = usePage<SharedData>();
const { user } = props.auth;
const { button } = props.translate;
const showWishlist = props.system.fields?.show_student_wishlist !== false;
const studentMenuItems = [
{
@ -17,12 +18,16 @@ const ProfileToggle = () => {
slug: 'courses',
Icon: GraduationCap,
},
...(showWishlist
? [
{
id: nanoid(),
name: button.wishlist,
slug: 'wishlist',
Icon: Heart,
},
]
: []),
{
id: nanoid(),
name: button.profile,

View File

@ -1,5 +1,6 @@
import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { SidebarMenuButton, useSidebar } from '@/components/ui/sidebar';
import { useIsMobile } from '@/hooks/use-mobile';
import { routeLastSegment, routeSecondSegment } from '@/lib/route';
import { cn } from '@/lib/utils';
import { SharedData } from '@/types/global';
@ -14,6 +15,7 @@ const NavMainItem = (props: NavMainItemProps) => {
const page = usePage<SharedData>();
const { auth, direction } = page.props;
const { state, toggleSidebar } = useSidebar();
const isMobile = useIsMobile();
const { pageRoute } = props;
const { Icon, name, path, children, slug } = pageRoute;
@ -67,7 +69,7 @@ const NavMainItem = (props: NavMainItemProps) => {
if (access.includes(auth.user.role)) {
return (
<SidebarMenuButton asChild key={index} isActive={activeChildRoute(pageRoute.slug, slug)} className="h-9 px-3">
<Link href={path} prefetch>
<Link href={path} prefetch={!isMobile}>
<Dot className="w-12" />
<span className="text-sm font-normal capitalize">{name}</span>
</Link>
@ -88,7 +90,7 @@ const NavMainItem = (props: NavMainItemProps) => {
: '',
)}
>
<Link href={path} prefetch>
<Link href={path} prefetch={!isMobile}>
<Icon className="h-4 w-4" />
<span>{name}</span>
</Link>

View File

@ -7,12 +7,13 @@ import { usePage } from '@inertiajs/react';
import { GitCompareArrows } from 'lucide-react';
import { useEffect, useState } from 'react';
import NavMainItem from './nav-main-item';
import routes from './routes';
import getRoutes from './routes';
export function NavMain() {
const page = usePage<SharedData>();
const { auth, system } = page.props;
const { auth, system, translate } = page.props;
const [openAccordions, setOpenAccordions] = useState<string>('');
const routes = getRoutes(translate);
// Set initial accordion state based on URL
useEffect(() => {
@ -47,7 +48,7 @@ export function NavMain() {
<SidebarMenuButton asChild className={cn('h-9')}>
<a target="_blank" href={route('system.maintenance')}>
<GitCompareArrows className="h-4 w-4" />
<span>Maintenance</span>
<span>{translate.button.maintenance}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>

View File

@ -1,14 +1,19 @@
import { routeLastSegment } from '@/lib/route';
import { Award, Book, Briefcase, CassetteTape, CreditCard, LayoutDashboard, Newspaper, Receipt, School, Settings, Users } from 'lucide-react';
const dashboardRoutes: DashboardRoute[] = [
const label = (value: unknown, fallback: string): string => (typeof value === 'string' && value.length ? value : fallback);
export default function getDashboardRoutes(translate: LanguageTranslations): DashboardRoute[] {
const { button, settings } = translate;
return [
{
title: 'Main Menu',
title: label(button.main_menu, 'Main Menu'),
slug: 'main-menu',
pages: [
{
Icon: LayoutDashboard,
name: 'Dashboard',
name: label(button.dashboard, 'Dashboard'),
path: route('dashboard'),
slug: routeLastSegment(route('dashboard')),
active: true,
@ -17,32 +22,32 @@ const dashboardRoutes: DashboardRoute[] = [
},
{
Icon: School,
name: 'Courses',
name: label(button.courses, 'Courses'),
path: '',
slug: 'courses',
active: true,
access: ['admin', 'instructor', 'collaborative', 'administrative'],
children: [
{
name: 'Categories',
name: label(button.categories, 'Categories'),
path: route('categories.index'),
slug: routeLastSegment(route('categories.index')),
access: ['admin', 'collaborative', 'administrative'],
},
{
name: 'Manage Courses',
name: label(button.manage_courses, 'Manage Courses'),
slug: routeLastSegment(route('courses.index')),
path: route('courses.index'),
access: ['admin', 'instructor', 'collaborative', 'administrative'],
},
{
name: 'Create Course',
name: label(button.create_course, 'Create Course'),
slug: routeLastSegment(route('courses.create')),
path: route('courses.create'),
access: ['admin', 'instructor', 'collaborative', 'administrative'],
},
{
name: 'Course Coupons',
name: label(button.course_coupons, 'Course Coupons'),
slug: routeLastSegment(route('course-coupons.index')),
path: route('course-coupons.index'),
access: ['admin', 'instructor', 'collaborative', 'administrative'],
@ -51,32 +56,32 @@ const dashboardRoutes: DashboardRoute[] = [
},
{
Icon: Book,
name: 'Exams',
name: label(button.exams, 'Exams'),
path: '',
slug: 'exams',
active: true,
access: ['admin', 'instructor', 'collaborative', 'administrative'],
children: [
{
name: 'Categories',
name: label(button.categories, 'Categories'),
slug: routeLastSegment(route('exam-categories.index')),
path: route('exam-categories.index'),
access: ['admin', 'collaborative', 'administrative'],
},
{
name: 'Manage Exams',
name: label(button.manage_exams, 'Manage Exams'),
slug: routeLastSegment(route('exams.index')),
path: route('exams.index'),
access: ['admin', 'instructor', 'collaborative', 'administrative'],
},
{
name: 'Create Exam',
name: label(button.create_exam, 'Create Exam'),
slug: routeLastSegment(route('exams.create')),
path: route('exams.create'),
access: ['admin', 'instructor', 'collaborative', 'administrative'],
},
{
name: 'Exam Coupons',
name: label(button.exam_coupons, 'Exam Coupons'),
slug: routeLastSegment(route('exam-coupons.index')),
path: route('exam-coupons.index'),
access: ['admin', 'instructor', 'collaborative', 'administrative'],
@ -85,20 +90,20 @@ const dashboardRoutes: DashboardRoute[] = [
},
{
Icon: CassetteTape,
name: 'Enrollments',
name: label(button.enrollments, 'Enrollments'),
path: '',
slug: 'enrollments',
active: true,
access: ['admin', 'instructor', 'collaborative', 'administrative'],
children: [
{
name: 'Course Enrollments',
name: label(button.course_enrollments, 'Course Enrollments'),
slug: routeLastSegment(route('course-enrollments.index')),
path: route('course-enrollments.index'),
access: ['admin', 'instructor', 'collaborative', 'administrative'],
},
{
name: 'Exam Enrollments',
name: label(button.exam_enrollments, 'Exam Enrollments'),
slug: routeLastSegment(route('exam-enrollments.index')),
path: route('exam-enrollments.index'),
access: ['admin', 'instructor', 'collaborative', 'administrative'],
@ -107,26 +112,26 @@ const dashboardRoutes: DashboardRoute[] = [
},
{
Icon: Users,
name: 'Instructors',
name: label(button.instructors, 'Instructors'),
path: '',
slug: 'instructors',
active: true,
access: ['admin', 'collaborative'],
children: [
{
name: 'Manage Instructors',
name: label(button.manage_instructors, 'Manage Instructors'),
slug: routeLastSegment(route('instructors.index')),
path: route('instructors.index'),
access: ['admin', 'collaborative'],
},
{
name: 'Create Instructor',
name: label(button.create_instructor, 'Create Instructor'),
slug: routeLastSegment(route('instructors.create')),
path: route('instructors.create'),
access: ['admin', 'collaborative'],
},
{
name: 'Applications',
name: label(button.applications, 'Applications'),
slug: routeLastSegment(route('instructors.applications')),
path: route('instructors.applications', {
status: 'pending',
@ -137,20 +142,20 @@ const dashboardRoutes: DashboardRoute[] = [
},
{
Icon: Receipt,
name: 'Payouts',
name: label(button.payouts, 'Payouts'),
path: '',
slug: 'payouts',
active: true,
access: ['instructor', 'collaborative'],
children: [
{
name: 'Withdraw',
name: label(button.withdraw, 'Withdraw'),
slug: routeLastSegment(route('payouts.index')),
path: route('payouts.index'),
access: ['instructor', 'collaborative'],
},
{
name: 'Settings',
name: label(button.settings, 'Settings'),
slug: routeLastSegment(route('payouts.settings.index')),
path: route('payouts.settings.index'),
access: ['instructor', 'collaborative'],
@ -159,20 +164,20 @@ const dashboardRoutes: DashboardRoute[] = [
},
{
Icon: Receipt,
name: 'Payout Report',
name: label(button.payout_report, 'Payout Report'),
path: '',
slug: 'payouts',
active: true,
access: ['admin', 'collaborative'],
children: [
{
name: 'Payout Request',
name: label(button.payout_request, 'Payout Request'),
slug: routeLastSegment(route('payouts.request.index')),
path: route('payouts.request.index'),
access: ['admin', 'collaborative'],
},
{
name: 'Payout History',
name: label(button.payout_history, 'Payout History'),
slug: routeLastSegment(route('payouts.history.index')),
path: route('payouts.history.index'),
access: ['admin', 'collaborative'],
@ -181,20 +186,20 @@ const dashboardRoutes: DashboardRoute[] = [
},
{
Icon: CreditCard,
name: 'Payment Report',
name: label(button.payment_report, 'Payment Report'),
path: '',
slug: 'payment-reports',
active: true,
access: ['admin', 'collaborative', 'administrative'],
children: [
{
name: 'Online Payments',
name: label(button.online_payments, 'Online Payments'),
slug: routeLastSegment(route('payment-reports.online.index')),
path: route('payment-reports.online.index'),
access: ['admin', 'collaborative', 'administrative'],
},
{
name: 'Offline Payments',
name: label(button.offline_payments, 'Offline Payments'),
slug: routeLastSegment(route('payment-reports.offline.index')),
path: route('payment-reports.offline.index'),
access: ['admin', 'collaborative', 'administrative'],
@ -203,20 +208,20 @@ const dashboardRoutes: DashboardRoute[] = [
},
{
Icon: Briefcase,
name: 'Job Circulars',
name: label(button.job_circulars, 'Job Circulars'),
path: '',
slug: 'job-circulars',
active: true,
access: ['admin', 'collaborative', 'administrative'],
children: [
{
name: 'All Jobs',
name: label(button.all_jobs, 'All Jobs'),
slug: routeLastSegment(route('job-circulars.index')),
path: route('job-circulars.index'),
access: ['admin', 'collaborative', 'administrative'],
},
{
name: 'Create Job',
name: label(button.create_job, 'Create Job'),
slug: routeLastSegment(route('job-circulars.create')),
path: route('job-circulars.create'),
access: ['admin', 'collaborative', 'administrative'],
@ -225,26 +230,26 @@ const dashboardRoutes: DashboardRoute[] = [
},
{
Icon: Book,
name: 'Blogs',
name: label(button.blogs, 'Blogs'),
path: '',
slug: 'blogs',
active: true,
access: ['admin', 'instructor', 'collaborative', 'administrative'],
children: [
{
name: 'Categories',
name: label(button.categories, 'Categories'),
slug: routeLastSegment(route('blogs.categories.index')),
path: route('blogs.categories.index'),
access: ['admin', 'instructor', 'collaborative', 'administrative'],
},
{
name: 'Create Blog',
name: label(button.create_blog, 'Create Blog'),
slug: routeLastSegment(route('blogs.create')),
path: route('blogs.create'),
access: ['admin', 'instructor', 'collaborative', 'administrative'],
},
{
name: 'Manage Blog',
name: label(button.manage_blog, 'Manage Blog'),
slug: routeLastSegment(route('blogs.index')),
path: route('blogs.index'),
access: ['admin', 'instructor', 'collaborative', 'administrative'],
@ -253,7 +258,7 @@ const dashboardRoutes: DashboardRoute[] = [
},
{
Icon: Newspaper,
name: 'Newsletters',
name: label(button.newsletters, 'Newsletters'),
path: route('newsletters.index'),
slug: routeLastSegment(route('newsletters.index')),
active: true,
@ -262,7 +267,7 @@ const dashboardRoutes: DashboardRoute[] = [
},
{
Icon: Users,
name: 'All Users',
name: label(button.all_users, 'All Users'),
path: route('users.index'),
slug: routeLastSegment(route('users.index')),
active: true,
@ -271,20 +276,20 @@ const dashboardRoutes: DashboardRoute[] = [
},
{
Icon: Award,
name: 'Certificates',
name: label(button.certificates, 'Certificates'),
path: '',
slug: 'certification',
active: true,
access: ['admin', 'collaborative', 'administrative'],
children: [
{
name: 'Certificate',
name: label(button.certificate, 'Certificate'),
slug: routeLastSegment(route('certificate.templates.index')),
path: route('certificate.templates.index'),
access: ['admin', 'collaborative', 'administrative'],
},
{
name: 'Marksheet',
name: label(button.marksheet, 'Marksheet'),
slug: routeLastSegment(route('marksheet.templates.index')),
path: route('marksheet.templates.index'),
access: ['admin', 'collaborative', 'administrative'],
@ -293,62 +298,62 @@ const dashboardRoutes: DashboardRoute[] = [
},
{
Icon: Settings,
name: 'Settings',
name: label(button.settings, 'Settings'),
path: '',
slug: 'settings',
active: true,
access: ['admin', 'instructor', 'collaborative', 'administrative'],
children: [
{
name: 'Account',
name: label(button.account, 'Account'),
slug: routeLastSegment(route('settings.account')),
path: route('settings.account'),
access: ['admin', 'instructor', 'collaborative', 'administrative'],
},
{
name: 'System',
name: label(button.system, 'System'),
slug: routeLastSegment(route('settings.system')),
path: route('settings.system'),
access: ['admin', 'collaborative', 'administrative'],
},
{
name: 'Pages',
name: label(button.pages, 'Pages'),
slug: routeLastSegment(route('settings.pages')),
path: route('settings.pages'),
access: ['admin', 'collaborative', 'administrative'],
},
{
name: 'Storage',
name: label(button.storage, 'Storage'),
slug: routeLastSegment(route('settings.storage')),
path: route('settings.storage'),
access: ['admin', 'collaborative', 'administrative'],
},
{
name: 'Payment',
name: label(button.payment, 'Payment'),
slug: routeLastSegment(route('settings.payment')),
path: route('settings.payment'),
access: ['admin', 'collaborative', 'administrative'],
},
{
name: 'SMTP',
name: label(button.smtp, 'SMTP'),
slug: routeLastSegment(route('settings.smtp')),
path: route('settings.smtp'),
access: ['admin', 'collaborative', 'administrative'],
},
{
name: 'Auth',
name: label(button.auth0, 'Auth'),
slug: routeLastSegment(route('settings.auth0')),
path: route('settings.auth0'),
access: ['admin', 'collaborative', 'administrative'],
},
{
name: 'Live Class',
name: label(button.live_class, 'Live Class'),
slug: routeLastSegment(route('settings.live-class')),
path: route('settings.live-class'),
access: ['admin', 'collaborative', 'administrative'],
},
{
name: 'Translation',
name: label(settings.translation_settings, 'Translation'),
slug: routeLastSegment(route('language.index')),
path: route('language.index'),
access: ['admin', 'collaborative', 'administrative'],
@ -358,5 +363,4 @@ const dashboardRoutes: DashboardRoute[] = [
],
},
];
export default dashboardRoutes;
}

View File

@ -1,5 +1,6 @@
import AppLogo from '@/components/app-logo';
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuItem, useSidebar } from '@/components/ui/sidebar';
import { useIsMobile } from '@/hooks/use-mobile';
import { NavMain } from '@/layouts/dashboard/partials/nav-main';
import { NavUser } from '@/layouts/dashboard/partials/nav-user';
import { SharedData } from '@/types/global';
@ -9,6 +10,7 @@ const DashboardSidebar = () => {
const { state } = useSidebar();
const { props } = usePage<SharedData>();
const compact = state === 'collapsed' ? true : false;
const isMobile = useIsMobile();
return (
<Sidebar collapsible="icon" variant="inset" side={props.direction === 'rtl' ? 'right' : 'left'} className="shadow-md">
@ -16,7 +18,7 @@ const DashboardSidebar = () => {
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem className="pt-1 pb-5">
<Link href="/" prefetch>
<Link href="/" prefetch={!isMobile}>
<AppLogo className="h-[26px]" />
</Link>
</SidebarMenuItem>

View File

@ -19,6 +19,7 @@ const Actions = ({ language }: { language: boolean }) => {
const { screen } = useScreen();
const [open, setOpen] = useState(false);
const sortedItems = navbar.navbar_items.sort((a, b) => a.sort - b.sort);
const showCart = system.fields?.show_course_cart !== false;
const actionElements = () =>
sortedItems.map((item) => {
@ -28,7 +29,7 @@ const Actions = ({ language }: { language: boolean }) => {
return <Language key={item.id} />;
} else if (isLoggedIn && item.slug === 'notification') {
return <Notification key={item.id} />;
} else if (isLoggedIn && item.slug === 'cart') {
} else if (isLoggedIn && item.slug === 'cart' && showCart) {
return <CourseCart key={item.id} />;
} else {
return null;

View File

@ -21,6 +21,7 @@ const IntroNavbar = () => {
const navbar = getPageSection(page, 'navbar');
const user = auth.user;
const showWishlist = props.system.fields?.show_student_wishlist !== false;
const [isSticky, setIsSticky] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
@ -105,7 +106,7 @@ const IntroNavbar = () => {
)}
{(user.role === 'student' || user.role === 'instructor') &&
studentMenuItems.map(({ id, name, Icon, slug }) => (
getStudentMenuItems(showWishlist).map(({ id, name, Icon, slug }) => (
<DropdownMenuItem
key={id}
className="cursor-pointer px-3"
@ -216,19 +217,23 @@ const IntroNavbar = () => {
);
};
const studentMenuItems = [
const getStudentMenuItems = (showWishlist: boolean) => [
{
id: nanoid(),
name: 'My Courses',
slug: 'courses',
Icon: GraduationCap,
},
...(showWishlist
? [
{
id: nanoid(),
name: 'Wishlist',
slug: 'wishlist',
Icon: Heart,
},
]
: []),
{
id: nanoid(),
name: 'My Profile',

View File

@ -21,6 +21,7 @@ const LandingNavbar = () => {
const navbar = getPageSection(page, 'navbar');
const user = auth.user;
const showWishlist = props.system.fields?.show_student_wishlist !== false;
const [isSticky, setIsSticky] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
@ -92,12 +93,12 @@ const LandingNavbar = () => {
{(user.role === 'admin' || user.role === 'instructor') && (
<DropdownMenuItem className="cursor-pointer px-3" onClick={() => router.get(route('dashboard'))}>
<LayoutDashboard className="mr-1 h-4 w-4" />
<span>Dashboard</span>
<span>Übersicht</span>
</DropdownMenuItem>
)}
{(user.role === 'student' || user.role === 'instructor') &&
studentMenuItems.map(({ id, name, Icon, slug }) => (
getStudentMenuItems(showWishlist).map(({ id, name, Icon, slug }) => (
<DropdownMenuItem
key={id}
className="cursor-pointer px-3"
@ -110,7 +111,7 @@ const LandingNavbar = () => {
<DropdownMenuItem className="cursor-pointer px-3" onClick={() => router.post('/logout')}>
<LogOut className="mr-1 h-4 w-4" />
<span>Log Out</span>
<span>Abmelden</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -118,10 +119,10 @@ const LandingNavbar = () => {
) : (
<div className="hidden items-center gap-3 md:flex">
<Button asChild variant="outline" className="h-auto rounded-sm px-5 py-2.5 shadow-none">
<Link href={route('register')}>Sign Up</Link>
<Link href={route('register')}>Registrieren</Link>
</Button>
<Button asChild className="h-auto rounded-sm px-5 py-2.5 shadow-none">
<Link href={route('login')}>Log In</Link>
<Link href={route('login')}>Anmelden</Link>
</Button>
</div>
)}
@ -153,10 +154,10 @@ const LandingNavbar = () => {
user.role === 'admin' ? (
<>
<Link href={route('dashboard')} className="text-sm font-normal">
Dashboard
Übersicht
</Link>
<Button variant="outline" onClick={() => router.post(route('logout'))}>
Log Out
Abmelden
</Button>
</>
) : (
@ -167,19 +168,19 @@ const LandingNavbar = () => {
</Link>
)}
<Link href={route('student.index', { tab: 'courses' })} className="text-sm font-normal">
My Courses
Meine Kurse
</Link>
<Link href={route('student.index', { tab: 'wishlist' })} className="text-sm font-normal">
Wishlist
Wunschliste
</Link>
<Link href={route('student.index', { tab: 'profile' })} className="text-sm font-normal">
My Profile
Mein Profil
</Link>
<Link href={route('student.index', { tab: 'settings' })} className="text-sm font-normal">
Settings
Einstellungen
</Link>
<Button variant="secondary" onClick={() => router.post(route('logout'))}>
Log Out
Abmelden
</Button>
</>
)
@ -205,19 +206,23 @@ const LandingNavbar = () => {
);
};
const studentMenuItems = [
const getStudentMenuItems = (showWishlist: boolean) => [
{
id: nanoid(),
name: 'My Courses',
slug: 'courses',
Icon: GraduationCap,
},
...(showWishlist
? [
{
id: nanoid(),
name: 'Wishlist',
slug: 'wishlist',
Icon: Heart,
},
]
: []),
{
id: nanoid(),
name: 'My Profile',

View File

@ -29,7 +29,7 @@ export default function Recaptcha({ status }: { status?: string }) {
{button.submit}
</Button>
<Button onClick={() => router.post(route('logout'))} className="mx-auto block text-sm">
<Button type="button" onClick={() => router.post(route('logout'))} className="mx-auto block text-sm">
{button.logout}
</Button>
</form>

View File

@ -5,6 +5,7 @@ import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { CoursePlayerProps } from '@/types/page';
import { usePage } from '@inertiajs/react';
import { format, parseISO } from 'date-fns';
import { de } from 'date-fns/locale';
import jsPDF from 'jspdf';
import { Award, Calendar, Download, FileImage, FileText } from 'lucide-react';
import { useRef, useState } from 'react';
@ -17,7 +18,9 @@ const Certificate = () => {
const courseName = props.course.title;
const studentName = props.auth.user.name;
const completionDate = format(parseISO(props.watchHistory.completion_date), 'MMM d, yyyy');
const completionDate = props.watchHistory?.completion_date
? format(parseISO(props.watchHistory.completion_date), 'dd. MMMM yyyy', { locale: de })
: '';
const [downloadFormat, setDownloadFormat] = useState('png');
const certificateRef = useRef<HTMLDivElement>(null);
const dimensions = { width: 900, height: 600 }; // Standard
@ -49,7 +52,7 @@ const Certificate = () => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${studentName}_${courseName}_Certificate.png`;
a.download = `${studentName}_${courseName}_Zertifikat.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@ -86,7 +89,7 @@ const Certificate = () => {
pdf.addImage(imgData, 'PNG', 0, 0, dimensions.width, dimensions.height);
// Save the PDF
pdf.save(`${studentName}_${courseName}_Certificate.pdf`);
pdf.save(`${studentName}_${courseName}_Zertifikat.pdf`);
toast.success(frontend.pdf_certificate_saved);
};
@ -255,21 +258,21 @@ const Certificate = () => {
<RadioGroupItem className="cursor-pointer" value="png" id="png" />
<Label htmlFor="png" className="flex cursor-pointer items-center gap-2">
<FileImage className="h-4 w-4" />
PNG Image
PNG-Bild
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem className="cursor-pointer" value="pdf" id="pdf" />
<Label htmlFor="pdf" className="flex cursor-pointer items-center gap-2">
<FileText className="h-4 w-4" />
PDF Document
PDF-Dokument
</Label>
</div>
</RadioGroup>
<Button variant="outline" className="w-full" onClick={handleDownloadCertificate}>
<Download className="mr-2 h-4 w-4" />
Download as {downloadFormat.toUpperCase()}
Herunterladen als {downloadFormat.toUpperCase()}
</Button>
</div>
</Card>

View File

@ -27,8 +27,12 @@ interface ContentListProps {
const ContentList = ({ completedContents, courseCompletion }: ContentListProps) => {
const { props } = usePage<CoursePlayerProps>();
const { course, zoomConfig, section, watchHistory, translate, direction } = props;
const { button, common } = translate;
const { course, zoomConfig, section, watchHistory, translate, direction, system } = props;
const { button, common, frontend } = translate;
const showCourseCertificate = system?.fields?.show_course_certificate ?? true;
const showCourseMarksheet = system?.fields?.show_course_marksheet ?? showCourseCertificate;
const showCertificateTab = showCourseCertificate || showCourseMarksheet;
const certificateLabel = showCourseCertificate ? frontend.course_certificate : button.marksheet || 'Notenblatt';
// Get live classes from course data
const liveClasses = course.live_classes || [];
@ -103,7 +107,7 @@ const ContentList = ({ completedContents, courseCompletion }: ContentListProps)
</>
) : (
<div className="px-4 py-3 text-center">
<p>There is no lesson added</p>
<p>Keine Lektion vorhanden</p>
</div>
)}
</AccordionContent>
@ -115,11 +119,11 @@ const ContentList = ({ completedContents, courseCompletion }: ContentListProps)
<Link
href={route('student.course.show', {
id: course.id,
tab: 'certificate',
tab: showCertificateTab ? 'certificate' : 'modules',
})}
>
<Button className="w-full" variant="secondary" size="lg" disabled={courseCompletion.percentage !== '100.00'}>
Course Certificate
{showCertificateTab ? certificateLabel : 'Module'}
</Button>
</Link>
</div>
@ -127,11 +131,11 @@ const ContentList = ({ completedContents, courseCompletion }: ContentListProps)
<div>
{!watchHistory.next_watching_id ? (
<Button className="w-full" variant="secondary" size="lg" onClick={finishCourseHandler}>
Finish Course
Kurs abschließen
</Button>
) : (
<Button className="w-full" variant="secondary" size="lg" disabled>
Finish Course
Kurs abschließen
</Button>
)}
</div>
@ -139,7 +143,7 @@ const ContentList = ({ completedContents, courseCompletion }: ContentListProps)
</Accordion>
) : (
<div className="p-6 text-center">
<p>There is no section added</p>
<p>Kein Abschnitt vorhanden</p>
</div>
)}
</TabsContent>
@ -148,8 +152,8 @@ const ContentList = ({ completedContents, courseCompletion }: ContentListProps)
{liveClasses.length <= 0 ? (
<Card className="p-8 text-center">
<Calendar className="mx-auto mb-4 h-12 w-12 text-gray-400" />
<h3 className="mb-2 text-lg font-medium">No Live Classes Scheduled</h3>
<p className="text-gray-500">Schedule your first live class to get started with Zoom.</p>
<h3 className="mb-2 text-lg font-medium">Keine Live-Sitzungen geplant</h3>
<p className="text-gray-500">Plane deine erste Live-Session, um mit Zoom zu starten.</p>
</Card>
) : (
liveClasses.map((liveClass) => {
@ -178,7 +182,7 @@ const ContentList = ({ completedContents, courseCompletion }: ContentListProps)
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="item-1" className="bg-muted overflow-hidden rounded-lg border-none">
<AccordionTrigger className="[&[data-state=open]]:!bg-secondary-lighter px-3 py-1.5 text-sm font-normal hover:no-underline">
Class Note
Hinweis zur Sitzung
</AccordionTrigger>
<AccordionContent className="p-3">
<Renderer value={liveClass.class_note} />

View File

@ -17,6 +17,7 @@ const Navbar = () => {
const { translate } = props;
const { button } = translate;
const user = props.auth.user;
const showWishlist = props.system.fields?.show_student_wishlist !== false;
return (
<header className="bg-primary dark:bg-primary-dark text-primary-foreground dark:text-primary sticky top-0 z-50 h-[60px]">
@ -61,7 +62,7 @@ const Navbar = () => {
)}
{(user.role === 'student' || user.role === 'instructor') &&
getStudentMenuItems(button).map(({ id, name, Icon, slug }) => (
getStudentMenuItems(button, showWishlist).map(({ id, name, Icon, slug }) => (
<DropdownMenuItem
key={id}
className="cursor-pointer px-3"
@ -95,19 +96,23 @@ const Navbar = () => {
);
};
const getStudentMenuItems = (button: any) => [
const getStudentMenuItems = (button: any, showWishlist: boolean) => [
{
id: nanoid(),
name: button.my_courses,
slug: 'courses',
Icon: GraduationCap,
},
...(showWishlist
? [
{
id: nanoid(),
name: button.wishlist,
slug: 'wishlist',
Icon: Heart,
},
]
: []),
{
id: nanoid(),
name: button.profile,

View File

@ -29,6 +29,8 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
const [finished, setFinished] = useState(false);
const [currentTab, setCurrentTab] = useState('summary');
const submissions = quiz.quiz_submissions;
const attemptsUsed = submissions[0]?.attempts || 0;
const hasAttemptLimit = quiz.retake > 0;
const { data, setData, post, reset, processing } = useForm({
submission_id: submissions.length > 0 ? submissions[0].id : null,
@ -188,7 +190,7 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
</div>
<div className="flex gap-2 text-sm">
<p className="text-gray-500">{frontend.retake}</p>
<p>: {quiz.retake}</p>
<p>: {hasAttemptLimit ? quiz.retake : 'Unlimited'}</p>
</div>
</div>
<div className="space-y-2">
@ -196,7 +198,7 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
<div className="flex gap-2 text-sm">
<p className="text-gray-500">{frontend.retake_attempts}</p>
<p>: {submissions[0]?.attempts || 0}</p>
<p>: {attemptsUsed}</p>
</div>
<div className="flex gap-2 text-sm">
<p className="text-gray-500">{frontend.correct_answers}</p>
@ -218,7 +220,7 @@ const QuizViewer = ({ quiz }: QuizViewerProps) => {
</div>
<div className="flex justify-center p-6">
{submissions[0]?.attempts >= quiz.retake ? (
{hasAttemptLimit && attemptsUsed >= quiz.retake ? (
<Button type="button" size="lg">
{frontend.quiz_submitted}
</Button>

View File

@ -70,8 +70,9 @@ const EnrollmentButton = ({ auth, course }: { auth: Auth; course: Course }) => {
};
const EnrollOrPlayerButton = () => {
const { auth, course, enrollment, watchHistory, approvalStatus, wishlists, translate } = usePage<CourseDetailsProps>().props;
const { auth, course, enrollment, watchHistory, approvalStatus, wishlists, translate, system } = usePage<CourseDetailsProps>().props;
const { frontend } = translate;
const showWishlist = system.fields?.show_student_wishlist !== false;
// Compute access conditions - improves readability
const isEnrolled = !!enrollment;
@ -97,9 +98,11 @@ const EnrollOrPlayerButton = () => {
} else {
return (
<>
{showWishlist && (
<Button className="w-full px-1 sm:px-3" variant="outline" size="lg" onClick={handleWishlist}>
{isWishlisted ? frontend.remove_from_wishlist : frontend.add_to_wishlist}
</Button>
)}
<EnrollmentButton auth={auth} course={course} />
</>

View File

@ -117,7 +117,7 @@ const CoursePreview = () => {
<Mail className="h-5 w-5" />
{frontend.certificate_included}
</span>
<span>Yes</span>
<span>{course.certificate_included ? 'Ja' : 'Nein'}</span>
</div>
</div>
</div>

View File

@ -1,24 +1,61 @@
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import DashboardLayout from '@/layouts/dashboard/layout';
import { SharedData } from '@/types/global';
import { Head, Link } from '@inertiajs/react';
import { Head, Link, router } from '@inertiajs/react';
import { Award, Plus } from 'lucide-react';
import { useEffect, useState } from 'react';
import CertificateCard from './partials/certificate-card';
interface CertificatePageProps extends SharedData {
templates: CertificateTemplate[];
}
const CertificateIndex = ({ templates }: CertificatePageProps) => {
const CertificateIndex = ({ templates, system }: CertificatePageProps) => {
const examTemplates = templates.filter((template) => template.type === 'exam');
const courseTemplates = templates.filter((template) => template.type === 'course');
const initialCourseCertificateEnabled = system?.fields?.show_course_certificate ?? true;
const [courseCertificateEnabled, setCourseCertificateEnabled] = useState<boolean>(initialCourseCertificateEnabled);
useEffect(() => {
setCourseCertificateEnabled(initialCourseCertificateEnabled);
}, [initialCourseCertificateEnabled]);
const handleCourseCertificateToggle = (checked: boolean) => {
const previous = courseCertificateEnabled;
setCourseCertificateEnabled(checked);
router.post(
route('certification.settings.update'),
{ show_course_certificate: checked },
{ preserveScroll: true, preserveState: true, onError: () => setCourseCertificateEnabled(previous) },
);
};
return (
<>
<Head title="Certificate Templates" />
<div className="space-y-6">
<Card className="p-4">
<div className="flex items-center justify-between gap-4">
<div>
<Label htmlFor="show_course_certificate" className="text-base font-semibold">
Course Certificate (Student)
</Label>
<p className="text-muted-foreground text-sm">Enable/disable course certificates for students.</p>
</div>
<Switch
id="show_course_certificate"
checked={courseCertificateEnabled}
onCheckedChange={handleCourseCertificateToggle}
/>
</div>
</Card>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">Certificate Templates</h2>

View File

@ -1,24 +1,57 @@
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import DashboardLayout from '@/layouts/dashboard/layout';
import { SharedData } from '@/types/global';
import { Head, Link } from '@inertiajs/react';
import { Head, Link, router } from '@inertiajs/react';
import { ClipboardList, Plus } from 'lucide-react';
import { useEffect, useState } from 'react';
import MarkSheetCard from './partials/marksheet-card';
interface MarksheetPageProps extends SharedData {
templates: MarksheetTemplate[];
}
const MarksheetIndex = ({ templates }: MarksheetPageProps) => {
const MarksheetIndex = ({ templates, system }: MarksheetPageProps) => {
const examTemplates = templates.filter((template) => template.type === 'exam');
const courseTemplates = templates.filter((template) => template.type === 'course');
const initialCourseMarksheetEnabled = system?.fields?.show_course_marksheet ?? true;
const [courseMarksheetEnabled, setCourseMarksheetEnabled] = useState<boolean>(initialCourseMarksheetEnabled);
useEffect(() => {
setCourseMarksheetEnabled(initialCourseMarksheetEnabled);
}, [initialCourseMarksheetEnabled]);
const handleCourseMarksheetToggle = (checked: boolean) => {
const previous = courseMarksheetEnabled;
setCourseMarksheetEnabled(checked);
router.post(
route('certification.settings.update'),
{ show_course_marksheet: checked },
{ preserveScroll: true, preserveState: true, onError: () => setCourseMarksheetEnabled(previous) },
);
};
return (
<>
<Head title="Certificate & Marksheet Templates" />
<div className="space-y-6">
<Card className="p-4">
<div className="flex items-center justify-between gap-4">
<div>
<Label htmlFor="show_course_marksheet" className="text-base font-semibold">
Course Marksheet (Student)
</Label>
<p className="text-muted-foreground text-sm">Enable/disable course marksheets for students.</p>
</div>
<Switch id="show_course_marksheet" checked={courseMarksheetEnabled} onCheckedChange={handleCourseMarksheetToggle} />
</div>
</Card>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">Marksheet Templates</h2>

View File

@ -22,10 +22,10 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
secondaryColor: '#4b5563',
backgroundColor: '#dbeafe',
borderColor: '#f59e0b',
titleText: 'Certificate of Completion',
descriptionText: 'This certificate is proudly presented to',
completionText: 'for successfully completing the course',
footerText: 'Authorized Certificate',
titleText: 'Zertifikat über den Abschluss',
descriptionText: 'Dieses Zertifikat wird feierlich überreicht an',
completionText: 'für den erfolgreichen Abschluss des Kurses',
footerText: 'Offizielles Zertifikat',
fontFamily: 'serif',
},
});
@ -53,31 +53,31 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>Set the template name and activation status</CardDescription>
<CardTitle>Grundinformationen</CardTitle>
<CardDescription>Vorlagenname und Aktivierungsstatus festlegen</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="type">Template Type</Label>
<Label htmlFor="type">Vorlagentyp</Label>
<Select value={data.type} onValueChange={(value) => setData('type', value as any)}>
<SelectTrigger>
<SelectValue placeholder="Select template type" />
<SelectValue placeholder="Vorlagentyp wählen" />
</SelectTrigger>
<SelectContent>
<SelectItem value="course">Course</SelectItem>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="course">Kurs</SelectItem>
<SelectItem value="exam">Prüfung</SelectItem>
</SelectContent>
</Select>
{errors.type && <p className="text-sm text-red-500">{errors.type}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="name">Template Name</Label>
<Label htmlFor="name">Vorlagenname</Label>
<Input
id="name"
value={data.name}
onChange={(e) => setData('name', e.target.value)}
placeholder="e.g., Modern Blue Certificate"
placeholder="z. B. Modernes blaues Zertifikat"
/>
{errors.name && <p className="text-sm text-red-500">{errors.name}</p>}
</div>
@ -87,11 +87,11 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
<Card>
<CardHeader>
<CardTitle>Logo & Branding</CardTitle>
<CardDescription>Upload your institution or course logo</CardDescription>
<CardDescription>Logo Ihrer Institution oder des Kurses hochladen</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="logo">Logo Image</Label>
<Label htmlFor="logo">Logo-Bild</Label>
<div className="space-y-2">
{logoPreview && (
<div className="h-20 w-20 overflow-hidden rounded border">
@ -102,7 +102,7 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
<Input id="logo" type="file" accept="image/*" onChange={(e) => onLogoChange('logo', e.target.files?.[0])} />
</div>
</div>
<p className="text-muted-foreground text-xs">Recommended: PNG or SVG, max 1MB</p>
<p className="text-muted-foreground text-xs">Empfohlen: PNG oder SVG, max. 1MB</p>
<InputError message={errors.logo} />
</div>
</CardContent>
@ -110,13 +110,13 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
<Card>
<CardHeader>
<CardTitle>Colors</CardTitle>
<CardDescription>Customize the certificate color scheme</CardDescription>
<CardTitle>Farben</CardTitle>
<CardDescription>Farbgestaltung des Zertifikats anpassen</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="primaryColor">Primary Color</Label>
<Label htmlFor="primaryColor">Primärfarbe</Label>
<div className="flex gap-2">
<Input
id="primaryColor"
@ -134,7 +134,7 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
</div>
<div className="space-y-2">
<Label htmlFor="secondaryColor">Secondary Color</Label>
<Label htmlFor="secondaryColor">Sekundärfarbe</Label>
<div className="flex gap-2">
<Input
id="secondaryColor"
@ -152,7 +152,7 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
</div>
<div className="space-y-2">
<Label htmlFor="backgroundColor">Background Color</Label>
<Label htmlFor="backgroundColor">Hintergrundfarbe</Label>
<div className="flex gap-2">
<Input
id="backgroundColor"
@ -170,7 +170,7 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
</div>
<div className="space-y-2">
<Label htmlFor="borderColor">Border Color</Label>
<Label htmlFor="borderColor">Rahmenfarbe</Label>
<div className="flex gap-2">
<Input
id="borderColor"
@ -192,12 +192,12 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
<Card>
<CardHeader>
<CardTitle>Typography</CardTitle>
<CardDescription>Choose the font style for your certificate</CardDescription>
<CardTitle>Typografie</CardTitle>
<CardDescription>Schriftstil für das Zertifikat wählen</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="fontFamily">Font Family</Label>
<Label htmlFor="fontFamily">Schriftfamilie</Label>
<Select
value={data.template_data.fontFamily}
onValueChange={(value) => setData('template_data', { ...data.template_data, fontFamily: value })}
@ -206,10 +206,10 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="serif">Serif (Classic)</SelectItem>
<SelectItem value="serif">Serif (Klassisch)</SelectItem>
<SelectItem value="sans-serif">Sans Serif (Modern)</SelectItem>
<SelectItem value="monospace">Monospace (Technical)</SelectItem>
<SelectItem value="cursive">Cursive (Elegant)</SelectItem>
<SelectItem value="monospace">Monospace (Technisch)</SelectItem>
<SelectItem value="cursive">Kursive (Elegant)</SelectItem>
</SelectContent>
</Select>
</div>
@ -218,48 +218,48 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
<Card>
<CardHeader>
<CardTitle>Certificate Text</CardTitle>
<CardDescription>Customize the text content of your certificate</CardDescription>
<CardTitle>Zertifikattext</CardTitle>
<CardDescription>Textinhalt des Zertifikats anpassen</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="titleText">Title Text</Label>
<Label htmlFor="titleText">Titeltext</Label>
<Input
id="titleText"
value={data.template_data.titleText}
onChange={(e) => setData('template_data', { ...data.template_data, titleText: e.target.value })}
placeholder="Certificate of Completion"
placeholder="Zertifikat über den Abschluss"
/>
</div>
<div className="space-y-2">
<Label htmlFor="descriptionText">Description Text</Label>
<Label htmlFor="descriptionText">Beschreibungstext</Label>
<Textarea
id="descriptionText"
value={data.template_data.descriptionText}
onChange={(e) => setData('template_data', { ...data.template_data, descriptionText: e.target.value })}
placeholder="This certificate is proudly presented to"
placeholder="Dieses Zertifikat wird feierlich überreicht an"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="completionText">Completion Text</Label>
<Label htmlFor="completionText">Abschlusstext</Label>
<Input
id="completionText"
value={data.template_data.completionText}
onChange={(e) => setData('template_data', { ...data.template_data, completionText: e.target.value })}
placeholder="for successfully completing the course"
placeholder="für den erfolgreichen Abschluss des Kurses"
/>
</div>
<div className="space-y-2">
<Label htmlFor="footerText">Footer Text</Label>
<Label htmlFor="footerText">Fußzeilentext</Label>
<Input
id="footerText"
value={data.template_data.footerText}
onChange={(e) => setData('template_data', { ...data.template_data, footerText: e.target.value })}
placeholder="Authorized Certificate"
placeholder="Offizielles Zertifikat"
/>
</div>
</CardContent>
@ -267,7 +267,7 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
<Button onClick={handleSubmit} disabled={processing} className="w-full">
<Save className="mr-2 h-4 w-4" />
{processing ? 'Saving...' : template ? 'Update Template' : 'Create Template'}
{processing ? 'Speichere...' : template ? 'Vorlage aktualisieren' : 'Vorlage erstellen'}
</Button>
</div>
@ -275,8 +275,8 @@ const CertificateBuilderForm = ({ template }: { template?: CertificateTemplate |
<div className="lg:sticky lg:top-6">
<Card>
<CardHeader>
<CardTitle>Live Preview</CardTitle>
<CardDescription>See how your certificate will look</CardDescription>
<CardTitle>Echtzeit-Vorschau</CardTitle>
<CardDescription>So sieht Ihr Zertifikat aus</CardDescription>
</CardHeader>
<CardContent>
<CertificatePreview

View File

@ -146,7 +146,7 @@ const CertificatePreview = ({ template, studentName, courseName, completionDate,
fontFamily: template_data.fontFamily,
}}
>
Completed on: {completionDate}
Abgeschlossen am: {completionDate}
</p>
</div>

View File

@ -31,7 +31,7 @@ const QuizForm = ({ title, quiz, handler, sectionId }: Props) => {
course_id: props.course.id,
total_mark: quiz?.total_mark || '',
pass_mark: quiz?.pass_mark || '',
retake: quiz?.retake || 1,
retake: quiz?.retake ?? 1,
summary: quiz?.summary || '',
hours: quiz?.hours || '',
minutes: quiz?.minutes || '',
@ -135,7 +135,7 @@ const QuizForm = ({ title, quiz, handler, sectionId }: Props) => {
<div>
<Label>{input.retake_attempts}</Label>
<Input
min="1"
min="0"
required
type="number"
name="retake"
@ -143,6 +143,7 @@ const QuizForm = ({ title, quiz, handler, sectionId }: Props) => {
placeholder="00"
onChange={(e) => onHandleChange(e, setData)}
/>
<p className="mt-1 text-xs text-gray-500">0 = Unlimited attempts</p>
<InputError message={errors.retake} />
</div>
</div>

View File

@ -218,10 +218,14 @@ const CreateExam = (props: Props) => {
type="number"
name="max_attempts"
value={data.max_attempts.toString()}
onChange={(e) => setData('max_attempts', parseInt(e.target.value) || 1)}
placeholder="3"
min="1"
onChange={(e) => {
const value = parseInt(e.target.value, 10);
setData('max_attempts', Number.isNaN(value) ? 0 : value);
}}
placeholder="0"
min="0"
/>
<p className="mt-1 text-xs text-gray-500">0 = Unlimited attempts</p>
<InputError message={errors.max_attempts} />
</div>

View File

@ -12,6 +12,8 @@ interface Props {
}
const ExamSettingsForm = ({ data, setData, errors }: Props) => {
const attemptsValue = Number.isFinite(data.max_attempts) ? data.max_attempts : 0;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
@ -79,16 +81,18 @@ const ExamSettingsForm = ({ data, setData, errors }: Props) => {
<Label htmlFor="max_attempts">Maximum Attempts Allowed *</Label>
<div className="space-y-2">
<Slider
value={[data.max_attempts || 1]}
value={[attemptsValue]}
onValueChange={(values) => setData('max_attempts', values[0])}
min={1}
min={0}
max={10}
step={1}
className="py-4"
/>
<div className="flex justify-between text-sm text-gray-600">
<span>1 attempt</span>
<span className="font-semibold text-gray-900">{data.max_attempts || 1} attempt(s)</span>
<span>0 (Unlimited)</span>
<span className="font-semibold text-gray-900">
{attemptsValue === 0 ? 'Unlimited attempts' : `${attemptsValue} attempt(s)`}
</span>
<span>10 attempts</span>
</div>
</div>

View File

@ -15,7 +15,7 @@ const ExamSettings = () => {
duration_hours: exam.duration_hours || 1,
duration_minutes: exam.duration_minutes || 0,
pass_mark: exam.pass_mark || 50,
max_attempts: exam.max_attempts || 3,
max_attempts: exam.max_attempts ?? 3,
total_marks: exam.total_marks || 100,
});
@ -77,12 +77,15 @@ const ExamSettings = () => {
type="number"
name="max_attempts"
value={data.max_attempts.toString()}
onChange={(e) => setData('max_attempts', parseInt(e.target.value) || 1)}
placeholder="3"
min="1"
onChange={(e) => {
const value = parseInt(e.target.value, 10);
setData('max_attempts', Number.isNaN(value) ? 0 : value);
}}
placeholder="0"
min="0"
/>
<InputError message={errors.max_attempts} />
<p className="mt-1 text-xs text-gray-500">Maximum number of attempts allowed per student</p>
<p className="mt-1 text-xs text-gray-500">0 = Unlimited attempts</p>
</div>
<div>

View File

@ -110,7 +110,7 @@ const ShowExam = ({ exam, stats }: Props) => {
</div>
<div>
<p className="text-sm text-gray-600">Max Attempts</p>
<p className="font-semibold">{exam.max_attempts}</p>
<p className="font-semibold">{exam.max_attempts === 0 ? 'Unlimited' : exam.max_attempts}</p>
</div>
<div>
<p className="text-sm text-gray-600">Level</p>

View File

@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import currencies from '@/data/currencies';
import { onHandleChange } from '@/lib/inertia';
import { SharedData } from '@/types/global';
@ -24,6 +25,12 @@ const Website = () => {
const { translate } = props;
const { input, settings } = translate;
const featureDefaults = {
show_course_cart: props.system.fields?.show_course_cart ?? true,
show_student_exams: props.system.fields?.show_student_exams ?? true,
show_student_wishlist: props.system.fields?.show_student_wishlist ?? true,
};
const mediaFields: MediaFields = {
new_logo_dark: null,
new_logo_light: null,
@ -32,6 +39,7 @@ const Website = () => {
};
const { data, setData, post, errors, processing } = useForm({
...featureDefaults,
...(props.system.fields as SystemFields),
...(mediaFields as MediaFields),
});
@ -271,6 +279,40 @@ const Website = () => {
<InputError message={errors.instructor_revenue} />
</div>
)}
<div className="md:col-span-3 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm font-medium">Show Course Cart</Label>
<p className="text-muted-foreground text-xs">Enable/disable course cart page and icon.</p>
</div>
<Switch id="show_course_cart" checked={!!data.show_course_cart} onCheckedChange={(checked) => setData('show_course_cart', checked)} />
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm font-medium">Show Student Exams</Label>
<p className="text-muted-foreground text-xs">Toggle visibility of the student exams tab.</p>
</div>
<Switch
id="show_student_exams"
checked={!!data.show_student_exams}
onCheckedChange={(checked) => setData('show_student_exams', checked)}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm font-medium">Show Wishlist</Label>
<p className="text-muted-foreground text-xs">Toggle visibility of the student wishlist tab.</p>
</div>
<Switch
id="show_student_wishlist"
checked={!!data.show_student_wishlist}
onCheckedChange={(checked) => setData('show_student_wishlist', checked)}
/>
</div>
</div>
</div>
</div>

View File

@ -5,14 +5,14 @@ import { Card } from '@/components/ui/card';
import Dashboard from '@/layouts/dashboard/layout';
import { SharedData } from '@/types/global';
import { Head, Link, router, usePage } from '@inertiajs/react';
import { Pencil, Trash2 } from 'lucide-react';
import { Pencil, RefreshCw, Trash2 } from 'lucide-react';
import { ReactNode } from 'react';
import AddLanguage from './partials/add-language';
const Index = () => {
const { props } = usePage<SharedData>();
const { translate } = props;
const { settings, common } = translate;
const { settings, common, button: buttonLabels } = translate;
const languageStatus = (lang: Language, checked: boolean) => {
router.put(
@ -30,6 +30,12 @@ const Index = () => {
});
};
const syncFromFiles = (lang: Language) => {
router.post(route('language.sync', lang.code), {}, {
preserveScroll: true,
});
};
return (
<>
<Head title={settings.language_settings} />
@ -41,10 +47,10 @@ const Index = () => {
</div>
<div className="rounded-md bg-blue-50 p-4 text-sm text-blue-700">
<div className="mb-2 font-medium">Translation Scope Information</div>
<div className="mb-2 font-medium">{settings.translation_scope_information}</div>
<ul className="list-disc space-y-1 pl-5">
<li>Translations will be applied to dashboard interfaces (admin, instructor, student).</li>
<li>Public pages are not affected by these translations as they are fully customizable through the page editor.</li>
<li>{settings.translation_scope_dashboard}</li>
<li>{settings.translation_scope_public_pages}</li>
</ul>
</div>
@ -63,6 +69,9 @@ const Index = () => {
<Pencil className="h-4 w-4" />
</Button>
</Link>
<Button size="icon" variant="secondary" className="mr-3 h-7 w-7 rounded-full text-blue-500" onClick={() => syncFromFiles(lang)}>
<RefreshCw className="h-4 w-4" />
</Button>
<Switch disabled checked name={lang.code} />
</div>
</div>
@ -75,7 +84,7 @@ const Index = () => {
<div className="flex items-center gap-3">
{lang.is_active ? (
<Button onClick={() => defaultLanguage(lang)} size="sm" variant="secondary" className="rounded-full">
Set Default
{buttonLabels.set_default}
</Button>
) : null}
@ -84,6 +93,9 @@ const Index = () => {
<Pencil className="h-4 w-4" />
</Button>
</Link>
<Button size="icon" variant="secondary" className="h-7 w-7 rounded-full text-blue-500" onClick={() => syncFromFiles(lang)}>
<RefreshCw className="h-4 w-4" />
</Button>
<DeleteModal
routePath={route('language.destroy', lang.id)}

View File

@ -0,0 +1,83 @@
import InputError from '@/components/input-error';
import LoadingButton from '@/components/loading-button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { SharedData } from '@/types/global';
import { useForm, usePage } from '@inertiajs/react';
import { ReactNode, useState } from 'react';
interface Props {
actionComponent: ReactNode;
}
const InviteForm = ({ actionComponent }: Props) => {
const { props } = usePage<SharedData>();
const { translate } = props;
const { dashboard, input, common, button } = translate;
const [open, setOpen] = useState(false);
const { data, post, setData, processing, errors, reset } = useForm({
name: '',
email: '',
status: 1,
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
post(route('users.store'), {
onSuccess: () => {
reset();
setOpen(false);
},
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{actionComponent}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{dashboard.invite_user}</DialogTitle>
<p className="text-muted-foreground text-sm">{dashboard.invite_user_description}</p>
</DialogHeader>
<form onSubmit={handleSubmit} className="mt-4 space-y-4 text-start">
<div>
<Label>{input.name}</Label>
<Input required value={data.name} onChange={(e) => setData('name', e.target.value)} />
<InputError message={errors.name} />
</div>
<div>
<Label>{input.email}</Label>
<Input type="email" required value={data.email} onChange={(e) => setData('email', e.target.value)} />
<InputError message={errors.email} />
</div>
<div>
<Label>{input.status}</Label>
<Select value={data.status === 1 ? 'active' : 'inactive'} onValueChange={(value) => setData('status', value === 'active' ? 1 : 0)}>
<SelectTrigger>
<SelectValue placeholder={dashboard.select_approval_status} />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{common.active}</SelectItem>
<SelectItem value="inactive">{common.inactive}</SelectItem>
</SelectContent>
</Select>
<InputError message={errors.status} />
</div>
<LoadingButton loading={processing} className="w-full">
{button.send ?? button.submit}
</LoadingButton>
</form>
</DialogContent>
</Dialog>
);
};
export default InviteForm;

View File

@ -1,12 +1,15 @@
import DeleteModal from '@/components/inertia/delete-modal';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ColumnDef } from '@tanstack/react-table';
import { ArrowUpDown, Pencil, Trash2 } from 'lucide-react';
import EditForm from './edit-form';
const TableColumn = (translate: LanguageTranslations): ColumnDef<User>[] => {
const { table, common } = translate;
const verifiedLabel = common?.verified ?? 'Verified';
const notVerifiedLabel = common?.not_verified ?? 'Not Verified';
return [
{
@ -44,6 +47,15 @@ const TableColumn = (translate: LanguageTranslations): ColumnDef<User>[] => {
</div>
),
},
{
accessorKey: 'email_verified_at',
header: table?.verified ?? 'Verified',
cell: ({ row }) => (
<Badge variant={row.original.email_verified_at ? 'default' : 'secondary'}>
{row.original.email_verified_at ? verifiedLabel : notVerifiedLabel}
</Badge>
),
},
{
accessorKey: 'role',
header: table.role,

Some files were not shown because too many files have changed in this diff Show More