feat: add CommunityModal and CommunityChannelList components for community engagement. Integrate community channels into the sidebar and landing page, enhancing user interaction with WhatsApp and Discord links. Implement CommunityCard in account settings for quick access to community resources.
Browse files- apps/web/src/components/CommunityChannelList.tsx +90 -0
- apps/web/src/components/CommunityModal.tsx +39 -0
- apps/web/src/components/routes/LandingPage.tsx +21 -0
- apps/web/src/components/settings/AccountSettings.tsx +3 -0
- apps/web/src/components/settings/CommunityCard.tsx +18 -0
- apps/web/src/components/sidebar.tsx +16 -0
- apps/web/src/lib/community-links.ts +72 -0
- apps/web/src/routes/dashboard.tsx +17 -3
apps/web/src/components/CommunityChannelList.tsx
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 2 |
+
import {
|
| 3 |
+
COMMUNITY_PLATFORM_META,
|
| 4 |
+
getCommunitySections,
|
| 5 |
+
type CommunityChannel,
|
| 6 |
+
type CommunityPlatform,
|
| 7 |
+
} from "@/lib/community-links";
|
| 8 |
+
|
| 9 |
+
function ChannelRow({
|
| 10 |
+
channel,
|
| 11 |
+
platform,
|
| 12 |
+
variant,
|
| 13 |
+
}: {
|
| 14 |
+
channel: CommunityChannel;
|
| 15 |
+
platform: CommunityPlatform;
|
| 16 |
+
variant: "modal" | "card";
|
| 17 |
+
}) {
|
| 18 |
+
const meta = COMMUNITY_PLATFORM_META[platform];
|
| 19 |
+
const baseClass =
|
| 20 |
+
variant === "modal"
|
| 21 |
+
? `w-full flex items-start gap-3 py-3.5 px-4 rounded-[var(--radius-lg)] font-semibold transition-all text-[var(--clay-black)] border-2 clay-hover ${meta?.linkClass ?? ""}`
|
| 22 |
+
: `flex items-start gap-3 rounded-[var(--radius-lg)] border px-4 py-3 transition-colors ${meta?.linkClass ?? "border-[var(--oat-border)] bg-[var(--warm-cream)]/60"}`;
|
| 23 |
+
|
| 24 |
+
const iconClass = meta?.iconClass ?? "text-[var(--matcha-700)]";
|
| 25 |
+
|
| 26 |
+
if (!channel.href) {
|
| 27 |
+
return (
|
| 28 |
+
<div
|
| 29 |
+
className={`${baseClass} opacity-70 cursor-not-allowed border-dashed`}
|
| 30 |
+
aria-disabled="true"
|
| 31 |
+
>
|
| 32 |
+
<MaterialIcon name={channel.icon} className={`text-2xl shrink-0 mt-0.5 ${iconClass}`} />
|
| 33 |
+
<span className="text-left flex-1">
|
| 34 |
+
<span className="flex items-center gap-2 flex-wrap">
|
| 35 |
+
<span className="block font-bold">{channel.name}</span>
|
| 36 |
+
<span className="text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-full bg-[var(--oat-light)] text-[var(--warm-charcoal)]">
|
| 37 |
+
Segera hadir
|
| 38 |
+
</span>
|
| 39 |
+
</span>
|
| 40 |
+
<span className="block text-sm font-normal text-[var(--warm-charcoal)] mt-0.5">
|
| 41 |
+
{channel.description}
|
| 42 |
+
</span>
|
| 43 |
+
</span>
|
| 44 |
+
</div>
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<a
|
| 50 |
+
href={channel.href}
|
| 51 |
+
target="_blank"
|
| 52 |
+
rel="noopener noreferrer"
|
| 53 |
+
className={baseClass}
|
| 54 |
+
>
|
| 55 |
+
<MaterialIcon name={channel.icon} className={`text-2xl shrink-0 mt-0.5 ${iconClass}`} />
|
| 56 |
+
<span className="text-left">
|
| 57 |
+
<span className={`block font-bold ${variant === "card" ? "text-sm" : ""}`}>
|
| 58 |
+
{channel.name}
|
| 59 |
+
</span>
|
| 60 |
+
<span
|
| 61 |
+
className={`block font-normal text-[var(--warm-charcoal)] mt-0.5 ${variant === "card" ? "text-xs" : "text-sm"}`}
|
| 62 |
+
>
|
| 63 |
+
{channel.description}
|
| 64 |
+
</span>
|
| 65 |
+
</span>
|
| 66 |
+
</a>
|
| 67 |
+
);
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
export function CommunityChannelList({ variant }: { variant: "modal" | "card" }) {
|
| 71 |
+
const sections = getCommunitySections();
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
<div className={variant === "modal" ? "flex flex-col gap-5" : "space-y-5"}>
|
| 75 |
+
{sections.map(({ platform, meta, channels }) => (
|
| 76 |
+
<section key={platform} className="space-y-2">
|
| 77 |
+
<h3 className="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-[var(--warm-charcoal)]">
|
| 78 |
+
<MaterialIcon name={meta.icon} className={`text-base ${meta.iconClass}`} />
|
| 79 |
+
{meta.label}
|
| 80 |
+
</h3>
|
| 81 |
+
<div className={variant === "modal" ? "flex flex-col gap-2" : "space-y-2"}>
|
| 82 |
+
{channels.map((channel) => (
|
| 83 |
+
<ChannelRow key={channel.id} channel={channel} platform={platform} variant={variant} />
|
| 84 |
+
))}
|
| 85 |
+
</div>
|
| 86 |
+
</section>
|
| 87 |
+
))}
|
| 88 |
+
</div>
|
| 89 |
+
);
|
| 90 |
+
}
|
apps/web/src/components/CommunityModal.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@labas/ui/components/dialog";
|
| 2 |
+
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 3 |
+
import { CommunityChannelList } from "@/components/CommunityChannelList";
|
| 4 |
+
|
| 5 |
+
export function CommunityModal({
|
| 6 |
+
isOpen,
|
| 7 |
+
onOpenChange,
|
| 8 |
+
}: {
|
| 9 |
+
isOpen: boolean;
|
| 10 |
+
onOpenChange: (open: boolean) => void;
|
| 11 |
+
}) {
|
| 12 |
+
return (
|
| 13 |
+
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
| 14 |
+
<DialogContent className="sm:max-w-[440px] p-6 rounded-[var(--radius-xl)] bg-[var(--pure-white)] border-2 border-[var(--oat-border)]">
|
| 15 |
+
<DialogHeader>
|
| 16 |
+
<div className="mx-auto w-16 h-16 bg-[var(--matcha-300)]/30 rounded-full flex items-center justify-center mb-2">
|
| 17 |
+
<MaterialIcon name="forum" className="text-4xl text-[var(--matcha-800)]" />
|
| 18 |
+
</div>
|
| 19 |
+
<DialogTitle className="text-center text-2xl font-headline font-extrabold text-[var(--clay-black)]">
|
| 20 |
+
Gabung Komunitas Labas
|
| 21 |
+
</DialogTitle>
|
| 22 |
+
<DialogDescription className="text-center text-base text-[var(--warm-charcoal)] pt-2 leading-relaxed">
|
| 23 |
+
Belajar bahasa, lapor bug, atau ikut ngembangin Labas — pilih channel yang paling pas.
|
| 24 |
+
</DialogDescription>
|
| 25 |
+
</DialogHeader>
|
| 26 |
+
<div className="mt-6">
|
| 27 |
+
<CommunityChannelList variant="modal" />
|
| 28 |
+
<button
|
| 29 |
+
type="button"
|
| 30 |
+
onClick={() => onOpenChange(false)}
|
| 31 |
+
className="mt-5 w-full text-sm text-[var(--warm-silver)] hover:text-[var(--clay-black)] font-semibold transition-colors"
|
| 32 |
+
>
|
| 33 |
+
Nanti Saja
|
| 34 |
+
</button>
|
| 35 |
+
</div>
|
| 36 |
+
</DialogContent>
|
| 37 |
+
</Dialog>
|
| 38 |
+
);
|
| 39 |
+
}
|
apps/web/src/components/routes/LandingPage.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { Button } from "@labas/ui/components/button";
|
|
| 3 |
import { Card, CardContent } from "@labas/ui/components/card";
|
| 4 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 5 |
import { useState } from "react";
|
|
|
|
| 6 |
|
| 7 |
function FaqItem({ question, answer, isDark }: { question: string; answer: string; isDark?: boolean }) {
|
| 8 |
const [isOpen, setIsOpen] = useState(false);
|
|
@@ -37,8 +38,11 @@ function FaqItem({ question, answer, isDark }: { question: string; answer: strin
|
|
| 37 |
}
|
| 38 |
|
| 39 |
export function LandingPage() {
|
|
|
|
|
|
|
| 40 |
return (
|
| 41 |
<div className="min-h-screen bg-[var(--warm-cream)] flex flex-col font-sans selection:bg-[var(--matcha-300)] selection:text-[var(--clay-black)]">
|
|
|
|
| 42 |
{/* Navbar */}
|
| 43 |
<nav className="w-full px-6 py-4 md:px-12 lg:px-16 flex items-center justify-between max-w-7xl mx-auto z-50 sticky top-0 bg-[var(--warm-cream)] border-b-2 border-[var(--oat-border)]">
|
| 44 |
<div className="flex items-center gap-3">
|
|
@@ -46,6 +50,15 @@ export function LandingPage() {
|
|
| 46 |
<span className="font-headline font-semibold text-2xl tracking-[-0.64px] text-[var(--clay-black)] hidden sm:block">Labas</span>
|
| 47 |
</div>
|
| 48 |
<div className="flex items-center gap-2 sm:gap-4">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
<a href="https://saweria.co/rogasper" target="_blank" rel="noopener noreferrer" className="text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)] transition-colors p-2 hidden md:flex items-center gap-2" aria-label="Support via Saweria">
|
| 50 |
<MaterialIcon name="favorite" className="text-xl" />
|
| 51 |
<span className="font-semibold text-base">Support</span>
|
|
@@ -495,6 +508,14 @@ export function LandingPage() {
|
|
| 495 |
<MaterialIcon name="local_cafe" />
|
| 496 |
<span className="font-semibold">Ko-fi</span>
|
| 497 |
</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
<a href="https://rogasper.com" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:text-[var(--matcha-700)] transition-colors">
|
| 499 |
<MaterialIcon name="language" />
|
| 500 |
<span className="font-semibold">rogasper.com</span>
|
|
|
|
| 3 |
import { Card, CardContent } from "@labas/ui/components/card";
|
| 4 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 5 |
import { useState } from "react";
|
| 6 |
+
import { CommunityModal } from "@/components/CommunityModal";
|
| 7 |
|
| 8 |
function FaqItem({ question, answer, isDark }: { question: string; answer: string; isDark?: boolean }) {
|
| 9 |
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
export function LandingPage() {
|
| 41 |
+
const [communityModalOpen, setCommunityModalOpen] = useState(false);
|
| 42 |
+
|
| 43 |
return (
|
| 44 |
<div className="min-h-screen bg-[var(--warm-cream)] flex flex-col font-sans selection:bg-[var(--matcha-300)] selection:text-[var(--clay-black)]">
|
| 45 |
+
<CommunityModal isOpen={communityModalOpen} onOpenChange={setCommunityModalOpen} />
|
| 46 |
{/* Navbar */}
|
| 47 |
<nav className="w-full px-6 py-4 md:px-12 lg:px-16 flex items-center justify-between max-w-7xl mx-auto z-50 sticky top-0 bg-[var(--warm-cream)] border-b-2 border-[var(--oat-border)]">
|
| 48 |
<div className="flex items-center gap-3">
|
|
|
|
| 50 |
<span className="font-headline font-semibold text-2xl tracking-[-0.64px] text-[var(--clay-black)] hidden sm:block">Labas</span>
|
| 51 |
</div>
|
| 52 |
<div className="flex items-center gap-2 sm:gap-4">
|
| 53 |
+
<button
|
| 54 |
+
type="button"
|
| 55 |
+
onClick={() => setCommunityModalOpen(true)}
|
| 56 |
+
className="text-[var(--warm-charcoal)] hover:text-[var(--matcha-700)] transition-colors p-2 hidden md:flex items-center gap-2"
|
| 57 |
+
aria-label="Komunitas WhatsApp"
|
| 58 |
+
>
|
| 59 |
+
<MaterialIcon name="forum" className="text-xl" />
|
| 60 |
+
<span className="font-semibold text-base">Komunitas</span>
|
| 61 |
+
</button>
|
| 62 |
<a href="https://saweria.co/rogasper" target="_blank" rel="noopener noreferrer" className="text-[var(--warm-charcoal)] hover:text-[var(--pomegranate-400)] transition-colors p-2 hidden md:flex items-center gap-2" aria-label="Support via Saweria">
|
| 63 |
<MaterialIcon name="favorite" className="text-xl" />
|
| 64 |
<span className="font-semibold text-base">Support</span>
|
|
|
|
| 508 |
<MaterialIcon name="local_cafe" />
|
| 509 |
<span className="font-semibold">Ko-fi</span>
|
| 510 |
</a>
|
| 511 |
+
<button
|
| 512 |
+
type="button"
|
| 513 |
+
onClick={() => setCommunityModalOpen(true)}
|
| 514 |
+
className="flex items-center gap-2 hover:text-[#128C7E] transition-colors"
|
| 515 |
+
>
|
| 516 |
+
<MaterialIcon name="forum" />
|
| 517 |
+
<span className="font-semibold">Komunitas WhatsApp</span>
|
| 518 |
+
</button>
|
| 519 |
<a href="https://rogasper.com" target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 hover:text-[var(--matcha-700)] transition-colors">
|
| 520 |
<MaterialIcon name="language" />
|
| 521 |
<span className="font-semibold">rogasper.com</span>
|
apps/web/src/components/settings/AccountSettings.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { Button } from "@labas/ui/components/button";
|
|
| 6 |
import { toast } from "sonner";
|
| 7 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 8 |
import { authClient } from "@/lib/auth-client";
|
|
|
|
| 9 |
|
| 10 |
export function AccountSettings() {
|
| 11 |
const { data: session } = authClient.useSession();
|
|
@@ -158,6 +159,8 @@ export function AccountSettings() {
|
|
| 158 |
</form>
|
| 159 |
</CardContent>
|
| 160 |
</Card>
|
|
|
|
|
|
|
| 161 |
</div>
|
| 162 |
);
|
| 163 |
}
|
|
|
|
| 6 |
import { toast } from "sonner";
|
| 7 |
import { getErrorMessage } from "@/lib/error-utils";
|
| 8 |
import { authClient } from "@/lib/auth-client";
|
| 9 |
+
import { CommunityCard } from "@/components/settings/CommunityCard";
|
| 10 |
|
| 11 |
export function AccountSettings() {
|
| 12 |
const { data: session } = authClient.useSession();
|
|
|
|
| 159 |
</form>
|
| 160 |
</CardContent>
|
| 161 |
</Card>
|
| 162 |
+
|
| 163 |
+
<CommunityCard />
|
| 164 |
</div>
|
| 165 |
);
|
| 166 |
}
|
apps/web/src/components/settings/CommunityCard.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
|
| 2 |
+
import { CommunityChannelList } from "@/components/CommunityChannelList";
|
| 3 |
+
|
| 4 |
+
export function CommunityCard() {
|
| 5 |
+
return (
|
| 6 |
+
<Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
|
| 7 |
+
<CardHeader>
|
| 8 |
+
<CardTitle className="font-headline text-[var(--clay-black)]">Komunitas</CardTitle>
|
| 9 |
+
<CardDescription className="text-[var(--warm-charcoal)]">
|
| 10 |
+
WhatsApp untuk chat cepat; Discord untuk diskusi terstruktur (segera hadir).
|
| 11 |
+
</CardDescription>
|
| 12 |
+
</CardHeader>
|
| 13 |
+
<CardContent>
|
| 14 |
+
<CommunityChannelList variant="card" />
|
| 15 |
+
</CardContent>
|
| 16 |
+
</Card>
|
| 17 |
+
);
|
| 18 |
+
}
|
apps/web/src/components/sidebar.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { authClient } from "@/lib/auth-client";
|
|
| 5 |
import { trpc } from "@/utils/trpc";
|
| 6 |
import { useSidebar } from "@/hooks/use-sidebar";
|
| 7 |
import { triggerGlobalTour } from "@/components/TourGuide";
|
|
|
|
| 8 |
|
| 9 |
interface NavItem {
|
| 10 |
to: string;
|
|
@@ -91,6 +92,7 @@ export function Sidebar() {
|
|
| 91 |
const { collapsed, toggle } = useSidebar();
|
| 92 |
const { data: session } = authClient.useSession();
|
| 93 |
const isLoggedIn = !!session;
|
|
|
|
| 94 |
|
| 95 |
const { data: adminData } = useQuery(
|
| 96 |
trpc.admin.isAdmin.queryOptions(undefined, { enabled: isLoggedIn }),
|
|
@@ -104,6 +106,7 @@ export function Sidebar() {
|
|
| 104 |
|
| 105 |
return (
|
| 106 |
<>
|
|
|
|
| 107 |
{/* Desktop Sidebar */}
|
| 108 |
<aside
|
| 109 |
className={`fixed left-0 top-0 h-full flex flex-col z-40 bg-[var(--warm-cream)] border-r border-[var(--oat-border)] hidden md:flex transition-all duration-300 overflow-y-auto ${
|
|
@@ -182,6 +185,19 @@ export function Sidebar() {
|
|
| 182 |
);
|
| 183 |
})}
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
{/* Help Tour Button */}
|
| 186 |
<button
|
| 187 |
onClick={triggerGlobalTour}
|
|
|
|
| 5 |
import { trpc } from "@/utils/trpc";
|
| 6 |
import { useSidebar } from "@/hooks/use-sidebar";
|
| 7 |
import { triggerGlobalTour } from "@/components/TourGuide";
|
| 8 |
+
import { CommunityModal } from "@/components/CommunityModal";
|
| 9 |
|
| 10 |
interface NavItem {
|
| 11 |
to: string;
|
|
|
|
| 92 |
const { collapsed, toggle } = useSidebar();
|
| 93 |
const { data: session } = authClient.useSession();
|
| 94 |
const isLoggedIn = !!session;
|
| 95 |
+
const [communityModalOpen, setCommunityModalOpen] = useState(false);
|
| 96 |
|
| 97 |
const { data: adminData } = useQuery(
|
| 98 |
trpc.admin.isAdmin.queryOptions(undefined, { enabled: isLoggedIn }),
|
|
|
|
| 106 |
|
| 107 |
return (
|
| 108 |
<>
|
| 109 |
+
<CommunityModal isOpen={communityModalOpen} onOpenChange={setCommunityModalOpen} />
|
| 110 |
{/* Desktop Sidebar */}
|
| 111 |
<aside
|
| 112 |
className={`fixed left-0 top-0 h-full flex flex-col z-40 bg-[var(--warm-cream)] border-r border-[var(--oat-border)] hidden md:flex transition-all duration-300 overflow-y-auto ${
|
|
|
|
| 185 |
);
|
| 186 |
})}
|
| 187 |
|
| 188 |
+
<button
|
| 189 |
+
type="button"
|
| 190 |
+
onClick={() => setCommunityModalOpen(true)}
|
| 191 |
+
aria-label="Komunitas WhatsApp"
|
| 192 |
+
className={`flex items-center gap-3 rounded-[var(--radius-lg)] transition-all clay-hover cursor-pointer text-[var(--warm-charcoal)] hover:bg-[var(--oat-light)] hover:text-[var(--clay-black)] w-full ${
|
| 193 |
+
collapsed ? "justify-center py-3 px-2" : "py-3 px-3 text-left"
|
| 194 |
+
}`}
|
| 195 |
+
title={collapsed ? "Komunitas" : undefined}
|
| 196 |
+
>
|
| 197 |
+
<NavIcon name="forum" />
|
| 198 |
+
{!collapsed && <span className="text-sm font-medium">Komunitas</span>}
|
| 199 |
+
</button>
|
| 200 |
+
|
| 201 |
{/* Help Tour Button */}
|
| 202 |
<button
|
| 203 |
onClick={triggerGlobalTour}
|
apps/web/src/lib/community-links.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type CommunityPlatform = "whatsapp" | "discord";
|
| 2 |
+
|
| 3 |
+
export type CommunityChannel = {
|
| 4 |
+
id: string;
|
| 5 |
+
platform: CommunityPlatform;
|
| 6 |
+
name: string;
|
| 7 |
+
description: string;
|
| 8 |
+
/** null = belum live (tampil sebagai "Segera hadir") */
|
| 9 |
+
href: string | null;
|
| 10 |
+
icon: string;
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
export const COMMUNITY_PLATFORM_META: Record<
|
| 14 |
+
CommunityPlatform,
|
| 15 |
+
{ label: string; icon: string; linkClass: string; iconClass: string }
|
| 16 |
+
> = {
|
| 17 |
+
whatsapp: {
|
| 18 |
+
label: "WhatsApp",
|
| 19 |
+
icon: "chat",
|
| 20 |
+
linkClass:
|
| 21 |
+
"bg-[#25D366]/10 border-[#25D366]/30 hover:bg-[#25D366]/20",
|
| 22 |
+
iconClass: "text-[#128C7E]",
|
| 23 |
+
},
|
| 24 |
+
discord: {
|
| 25 |
+
label: "Discord",
|
| 26 |
+
icon: "forum",
|
| 27 |
+
linkClass:
|
| 28 |
+
"bg-[#5865F2]/10 border-[#5865F2]/30 hover:bg-[#5865F2]/20",
|
| 29 |
+
iconClass: "text-[#5865F2]",
|
| 30 |
+
},
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
/** Satu sumber data — tambah Discord cukup isi `href` di entri bawah. */
|
| 34 |
+
export const COMMUNITY_CHANNELS: CommunityChannel[] = [
|
| 35 |
+
{
|
| 36 |
+
id: "labas-whatsapp",
|
| 37 |
+
platform: "whatsapp",
|
| 38 |
+
name: "Labas — Open Source",
|
| 39 |
+
description: "Laporkan bug, usulkan fitur, atau diskusi pengembangan Labas.",
|
| 40 |
+
href: "https://chat.whatsapp.com/JT6bDIXMQ5x6SUQCA1aDWB",
|
| 41 |
+
icon: "groups",
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
id: "learning-whatsapp",
|
| 45 |
+
platform: "whatsapp",
|
| 46 |
+
name: "Rogasper Learning",
|
| 47 |
+
description: "Berbagi tips, trik, dan berita terbaru dari Rogasper Learning.",
|
| 48 |
+
href: "https://chat.whatsapp.com/IrQOUGB3jhR7WnOoilCill",
|
| 49 |
+
icon: "school",
|
| 50 |
+
},
|
| 51 |
+
// Aktifkan saat server Discord siap — cukup isi href:
|
| 52 |
+
{
|
| 53 |
+
id: "labas-discord",
|
| 54 |
+
platform: "discord",
|
| 55 |
+
name: "Labas Discord",
|
| 56 |
+
description: "Diskusi async, bug report terstruktur, dan pengumuman fitur baru.",
|
| 57 |
+
href: null,
|
| 58 |
+
icon: "chat",
|
| 59 |
+
},
|
| 60 |
+
];
|
| 61 |
+
|
| 62 |
+
export const COMMUNITY_PROMPT_STORAGE_KEY = "labas_community_prompt_seen";
|
| 63 |
+
|
| 64 |
+
const PLATFORM_ORDER: CommunityPlatform[] = ["whatsapp", "discord"];
|
| 65 |
+
|
| 66 |
+
export function getCommunitySections() {
|
| 67 |
+
return PLATFORM_ORDER.map((platform) => ({
|
| 68 |
+
platform,
|
| 69 |
+
meta: COMMUNITY_PLATFORM_META[platform],
|
| 70 |
+
channels: COMMUNITY_CHANNELS.filter((c) => c.platform === platform),
|
| 71 |
+
})).filter((section) => section.channels.length > 0);
|
| 72 |
+
}
|
apps/web/src/routes/dashboard.tsx
CHANGED
|
@@ -9,6 +9,8 @@ import { Card, CardContent } from "@labas/ui/components/card";
|
|
| 9 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 10 |
import { TourGuide } from "@/components/TourGuide";
|
| 11 |
import { DonationModal } from "@/components/DonationModal";
|
|
|
|
|
|
|
| 12 |
|
| 13 |
export const Route = createFileRoute("/dashboard")({
|
| 14 |
staticData: routeShell.app,
|
|
@@ -38,25 +40,36 @@ function HomeComponent() {
|
|
| 38 |
const { session } = Route.useRouteContext();
|
| 39 |
const [donationModalOpen, setDonationModalOpen] = useState(false);
|
| 40 |
const [donationTrigger, setDonationTrigger] = useState<"exam" | "generate" | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
useEffect(() => {
|
| 43 |
const prompt = localStorage.getItem("pendingDonationPrompt") as "exam" | "generate" | null;
|
| 44 |
const lastPromptStr = localStorage.getItem("lastDonationPromptTime");
|
| 45 |
const lastPromptTime = lastPromptStr ? parseInt(lastPromptStr, 10) : 0;
|
| 46 |
-
|
| 47 |
-
// 24 hours cooldown
|
| 48 |
const COOLDOWN = 24 * 60 * 60 * 1000;
|
| 49 |
const now = Date.now();
|
| 50 |
|
|
|
|
| 51 |
if (prompt) {
|
| 52 |
localStorage.removeItem("pendingDonationPrompt");
|
| 53 |
-
|
| 54 |
if (now - lastPromptTime > COOLDOWN) {
|
| 55 |
setDonationTrigger(prompt);
|
| 56 |
setDonationModalOpen(true);
|
| 57 |
localStorage.setItem("lastDonationPromptTime", now.toString());
|
|
|
|
| 58 |
}
|
| 59 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
}, []);
|
| 61 |
|
| 62 |
const overview = useQuery(trpc.stats.overview.queryOptions());
|
|
@@ -82,6 +95,7 @@ function HomeComponent() {
|
|
| 82 |
onOpenChange={setDonationModalOpen}
|
| 83 |
triggerAction={donationTrigger}
|
| 84 |
/>
|
|
|
|
| 85 |
{/* Header */}
|
| 86 |
<section className="mb-8">
|
| 87 |
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|
|
|
|
| 9 |
import { MaterialIcon } from "@/components/ui/MaterialIcon";
|
| 10 |
import { TourGuide } from "@/components/TourGuide";
|
| 11 |
import { DonationModal } from "@/components/DonationModal";
|
| 12 |
+
import { CommunityModal } from "@/components/CommunityModal";
|
| 13 |
+
import { COMMUNITY_PROMPT_STORAGE_KEY } from "@/lib/community-links";
|
| 14 |
|
| 15 |
export const Route = createFileRoute("/dashboard")({
|
| 16 |
staticData: routeShell.app,
|
|
|
|
| 40 |
const { session } = Route.useRouteContext();
|
| 41 |
const [donationModalOpen, setDonationModalOpen] = useState(false);
|
| 42 |
const [donationTrigger, setDonationTrigger] = useState<"exam" | "generate" | null>(null);
|
| 43 |
+
const [communityModalOpen, setCommunityModalOpen] = useState(false);
|
| 44 |
+
|
| 45 |
+
const handleCommunityModalChange = (open: boolean) => {
|
| 46 |
+
setCommunityModalOpen(open);
|
| 47 |
+
if (!open) {
|
| 48 |
+
localStorage.setItem(COMMUNITY_PROMPT_STORAGE_KEY, "1");
|
| 49 |
+
}
|
| 50 |
+
};
|
| 51 |
|
| 52 |
useEffect(() => {
|
| 53 |
const prompt = localStorage.getItem("pendingDonationPrompt") as "exam" | "generate" | null;
|
| 54 |
const lastPromptStr = localStorage.getItem("lastDonationPromptTime");
|
| 55 |
const lastPromptTime = lastPromptStr ? parseInt(lastPromptStr, 10) : 0;
|
|
|
|
|
|
|
| 56 |
const COOLDOWN = 24 * 60 * 60 * 1000;
|
| 57 |
const now = Date.now();
|
| 58 |
|
| 59 |
+
let showedDonation = false;
|
| 60 |
if (prompt) {
|
| 61 |
localStorage.removeItem("pendingDonationPrompt");
|
|
|
|
| 62 |
if (now - lastPromptTime > COOLDOWN) {
|
| 63 |
setDonationTrigger(prompt);
|
| 64 |
setDonationModalOpen(true);
|
| 65 |
localStorage.setItem("lastDonationPromptTime", now.toString());
|
| 66 |
+
showedDonation = true;
|
| 67 |
}
|
| 68 |
}
|
| 69 |
+
|
| 70 |
+
if (!showedDonation && !localStorage.getItem(COMMUNITY_PROMPT_STORAGE_KEY)) {
|
| 71 |
+
setCommunityModalOpen(true);
|
| 72 |
+
}
|
| 73 |
}, []);
|
| 74 |
|
| 75 |
const overview = useQuery(trpc.stats.overview.queryOptions());
|
|
|
|
| 95 |
onOpenChange={setDonationModalOpen}
|
| 96 |
triggerAction={donationTrigger}
|
| 97 |
/>
|
| 98 |
+
<CommunityModal isOpen={communityModalOpen} onOpenChange={handleCommunityModalChange} />
|
| 99 |
{/* Header */}
|
| 100 |
<section className="mb-8">
|
| 101 |
<h1 className="text-4xl font-headline font-extrabold text-[var(--clay-black)] tracking-tight">
|