Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{
}
392 changes: 392 additions & 0 deletions src/app/categories/[slug]/page.tsx

Large diffs are not rendered by default.

463 changes: 226 additions & 237 deletions src/app/dashboard/courses/[id]/learn/page.tsx

Large diffs are not rendered by default.

149 changes: 149 additions & 0 deletions src/components/category/CategoryHero.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
'use client';

import { BookOpen } from 'lucide-react';

// ── Category metadata ──────────────────────────────────────────────────────
// Maps category slugs to display name, emoji icon, gradient colours, and
// a short description used as the hero sub-heading.
export interface CategoryMeta {
name: string;
icon: string;
description: string;
gradient: string; // Tailwind bg-gradient-to-br class pair
}

export const CATEGORY_META: Record<string, CategoryMeta> = {
'tailoring': {
name: 'Tailoring',
icon: '🧵',
description: 'Master the art of sewing, pattern-making, and garment construction from beginner to professional level.',
gradient: 'from-purple-100 to-pink-100',
},
'baking': {
name: 'Baking',
icon: '🍰',
description: 'From bread to celebration cakes — learn professional baking and pastry techniques at your own pace.',
gradient: 'from-amber-100 to-orange-100',
},
'photography': {
name: 'Photography',
icon: '📷',
description: 'Capture stunning images and build a photography business with guidance from working professionals.',
gradient: 'from-sky-100 to-blue-100',
},
'makeup-artistry': {
name: 'Makeup Artistry',
icon: '💄',
description: 'Learn bridal, editorial, and special-effects makeup from top-rated artists across Africa.',
gradient: 'from-rose-100 to-pink-100',
},
'hairstyling': {
name: 'Hairstyling',
icon: '💇',
description: 'Cut, colour, and style — comprehensive hair courses for salon professionals and home enthusiasts.',
gradient: 'from-teal-100 to-emerald-100',
},
'nail-technology': {
name: 'Nail Technology',
icon: '💅',
description: 'Nail art, gel extensions, and nail care business skills — everything you need to build a clientele.',
gradient: 'from-fuchsia-100 to-purple-100',
},
'web-development': {
name: 'Web Development',
icon: '💻',
description: 'Build modern websites and web apps from scratch with industry-standard tools and frameworks.',
gradient: 'from-cyan-100 to-blue-100',
},
'business': {
name: 'Business',
icon: '💼',
description: 'Grow your entrepreneurial skills with courses on finance, marketing, management, and strategy.',
gradient: 'from-yellow-100 to-amber-100',
},
'culinary-arts': {
name: 'Culinary Arts',
icon: '👨‍🍳',
description: 'Master cooking techniques, food styling, and kitchen management from expert chefs.',
gradient: 'from-red-100 to-orange-100',
},
'fashion-design': {
name: 'Fashion Design',
icon: '👗',
description: 'Design, sketch, and produce garments — from concept to finished collection.',
gradient: 'from-violet-100 to-purple-100',
},
};

/** Fallback meta for slugs not in the map above */
export function getCategoryMeta(slug: string): CategoryMeta {
if (CATEGORY_META[slug]) return CATEGORY_META[slug];
// Try matching by converting slug to title-case
const name = slug
.split('-')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
return {
name,
icon: '🎓',
description: `Explore ${name} courses taught by expert instructors.`,
gradient: 'from-hamplard-lilac to-saffron-100',
};
}

// ── Component ──────────────────────────────────────────────────────────────

interface CategoryHeroProps {
slug: string;
courseCount: number;
}

export function CategoryHero({ slug, courseCount }: CategoryHeroProps) {
const meta = getCategoryMeta(slug);

return (
<section
className={`bg-gradient-to-br ${meta.gradient} border-b border-ink-100`}
aria-labelledby="category-hero-heading"
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 sm:py-14">
<div className="flex flex-col sm:flex-row sm:items-center gap-5">
{/* Icon bubble */}
<div
className="flex-shrink-0 w-16 h-16 sm:w-20 sm:h-20 rounded-2xl bg-white/70 backdrop-blur-sm shadow-sm flex items-center justify-center text-4xl sm:text-5xl select-none"
aria-hidden="true"
>
{meta.icon}
</div>

{/* Text */}
<div className="flex-1 min-w-0">
<p className="text-xs font-semibold uppercase tracking-widest text-ink-400 mb-1">
Category
</p>
<h1
id="category-hero-heading"
className="font-display text-2xl sm:text-3xl lg:text-4xl font-bold text-ink-900 leading-tight"
>
{meta.name}
</h1>
<p className="mt-2 text-sm sm:text-base text-ink-600 max-w-2xl leading-relaxed">
{meta.description}
</p>

{/* Course count badge */}
<div className="mt-4 inline-flex items-center gap-2 bg-white/70 backdrop-blur-sm border border-white/80 rounded-full px-4 py-1.5 shadow-sm">
<BookOpen className="w-3.5 h-3.5 text-hamplard-primary" aria-hidden="true" />
<span className="text-xs font-semibold text-ink-700">
{courseCount.toLocaleString()}{' '}
{courseCount === 1 ? 'course' : 'courses'} available
</span>
</div>
</div>
</div>
</div>
</section>
);
}

export default CategoryHero;
37 changes: 37 additions & 0 deletions src/components/category/CategorySortSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
'use client';

import { useRouter } from 'next/navigation';

interface CategorySortSelectProps {
slug: string;
activeSub: string;
activeSort: string;
}

export function CategorySortSelect({ slug, activeSub, activeSort }: CategorySortSelectProps) {
const router = useRouter();

function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
const params = new URLSearchParams();
if (activeSub) params.set('sub', activeSub);
params.set('sort', e.target.value);
router.push(`/categories/${slug}?${params.toString()}`);
}

return (
<div className="flex-shrink-0">
<label htmlFor="cat-sort" className="sr-only">Sort by</label>
<select
id="cat-sort"
value={activeSort}
onChange={handleChange}
className="select text-sm w-auto"
aria-label="Sort courses"
>
<option value="popular">Most Popular</option>
<option value="rated">Highest Rated</option>
<option value="newest">Newest</option>
</select>
</div>
);
}
117 changes: 117 additions & 0 deletions src/components/learn/AutoplayCountdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import { X, SkipForward } from 'lucide-react';

interface AutoplayCountdownProps {
/** Title of the next lecture */
nextTitle: string;
/** Seconds to count down before auto-navigating (default: 5) */
seconds?: number;
/** Called when the countdown reaches 0 */
onComplete: () => void;
/** Called when the user clicks Cancel */
onCancel: () => void;
}

export function AutoplayCountdown({
nextTitle,
seconds = 5,
onComplete,
onCancel,
}: AutoplayCountdownProps) {
const [remaining, setRemaining] = useState(seconds);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;

useEffect(() => {
if (remaining <= 0) {
onCompleteRef.current();
return;
}
const id = setTimeout(() => setRemaining((r) => r - 1), 1000);
return () => clearTimeout(id);
}, [remaining]);

/** Arc maths for the SVG countdown ring */
const radius = 22;
const circumference = 2 * Math.PI * radius;
const progress = remaining / seconds;
const dashOffset = circumference * (1 - progress);

return (
<div
className="absolute inset-0 z-20 flex items-end justify-center pb-16"
aria-live="polite"
aria-label={`Autoplay next lecture in ${remaining} seconds`}
>
{/* Frosted backdrop strip */}
<div className="mx-4 w-full max-w-sm rounded-2xl bg-black/70 backdrop-blur-sm px-4 py-3 flex items-center gap-3 shadow-xl border border-white/10">
{/* Countdown ring */}
<div className="relative flex-shrink-0 w-12 h-12">
<svg className="w-12 h-12 -rotate-90" viewBox="0 0 56 56" aria-hidden="true">
{/* Track */}
<circle
cx="28"
cy="28"
r={radius}
fill="none"
stroke="rgba(255,255,255,0.15)"
strokeWidth="3"
/>
{/* Progress */}
<circle
cx="28"
cy="28"
r={radius}
fill="none"
stroke="#f59e0b"
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={dashOffset}
style={{ transition: 'stroke-dashoffset 1s linear' }}
/>
</svg>
{/* Number */}
<span className="absolute inset-0 flex items-center justify-center text-white text-sm font-bold tabular-nums">
{remaining}
</span>
</div>

{/* Text */}
<div className="flex-1 min-w-0">
<p className="text-white/70 text-[11px] uppercase tracking-wider font-medium">
Up next
</p>
<p className="text-white text-sm font-semibold truncate leading-snug mt-0.5">
{nextTitle}
</p>
</div>

{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* Play now */}
<button
onClick={onComplete}
className="flex items-center gap-1.5 rounded-full bg-saffron-500 hover:bg-saffron-400 active:bg-saffron-600 text-white text-xs font-medium px-3 py-1.5 transition-colors"
aria-label="Play next lecture now"
>
<SkipForward className="w-3.5 h-3.5" />
Play
</button>
{/* Cancel */}
<button
onClick={onCancel}
className="p-1.5 rounded-full text-white/60 hover:text-white hover:bg-white/10 transition-colors"
aria-label="Cancel autoplay"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
}

export default AutoplayCountdown;
Loading