feat: implement PWA scaffold, dark design system, and 3-tab glassmorphism nav
- Add @serwist/next for PWA service worker (precaching, offline support) - Create public/manifest.json with dark theme (#0a0a0f) and SVG icons - Rewrite globals.css with full dark premium design system (Tailwind v4) - Design tokens: bg-deep, bg-card, gold, nur-purple, emerald, borders, text - Utility classes: .glass, .glass-light, .gold-gradient, .nur-gradient, .gold-purple-gradient - Prayer ring SVG styles - Replace old 5-tab nav with 3-tab floating glassmorphism pill - Home (House), Nur center raised 56px gold-purple circle, Profile (User) - Active gold #D4AF37, inactive #666, safe-area-aware - Create src/lib/api/wordpress.ts fetchAPI wrapper with JWT auth - Update layout.tsx with manifest, apple-web-app, Viewport export
This commit is contained in:
+10
-4
@@ -1,7 +1,13 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from 'next'
|
||||||
|
import withSerwistInit from '@serwist/next'
|
||||||
|
|
||||||
|
const withSerwist = withSerwistInit({
|
||||||
|
swSrc: 'src/sw.ts',
|
||||||
|
swDest: 'public/sw.js',
|
||||||
|
})
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: process.env.NETLIFY === "true" ? undefined : "standalone",
|
output: process.env.NETLIFY === 'true' ? undefined : 'standalone',
|
||||||
};
|
}
|
||||||
|
|
||||||
export default nextConfig;
|
export default withSerwist(nextConfig)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
|
"@serwist/cli": "^9.5.11",
|
||||||
|
"@serwist/next": "^9.5.11",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/leaflet": "^1.9.14",
|
"@types/leaflet": "^1.9.14",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
|
"serwist": "^9.5.11",
|
||||||
"stripe": "^17.0.0"
|
"stripe": "^17.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
||||||
|
<rect width="192" height="192" rx="32" fill="#0a0a0f"/>
|
||||||
|
<path d="M86 36c-28 8-50 34-50 64 0 36 28 64 64 64 18 0 34-8 46-20-6 2-12 4-18 4-30 0-54-24-54-54 0-22 14-42 34-50l-22-8z" fill="#D4AF37"/>
|
||||||
|
<path d="M114 56l8 22 20 4-16 14 6 22-18-10-18 10 6-22-16-14 20-4z" fill="#A78BFA"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 382 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="64" fill="#0a0a0f"/>
|
||||||
|
<path d="M220 72c-74 20-130 90-130 170 0 96 76 172 172 172 48 0 90-20 122-54-16 6-32 10-48 10-82 0-146-64-146-146 0-58 36-110 90-134l-60-18z" fill="#D4AF37"/>
|
||||||
|
<path d="M308 144l20 58 54 10-42 36 14 58-48-26-48 26 14-58-42-36 54-10z" fill="#A78BFA"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 407 B |
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "Falah",
|
||||||
|
"short_name": "Falah",
|
||||||
|
"description": "Your Islamic lifestyle companion",
|
||||||
|
"start_url": "/home",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#0a0a0f",
|
||||||
|
"theme_color": "#0a0a0f",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.svg",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.svg",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+80
-15
@@ -1,25 +1,40 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@theme {
|
||||||
|
--color-bg-deep: #0a0a0f;
|
||||||
|
--color-bg-card: #111118;
|
||||||
|
--color-gold: #D4AF37;
|
||||||
|
--color-gold-light: #e2c55e;
|
||||||
|
--color-nur-purple: #A78BFA;
|
||||||
|
--color-nur-bg: #1a1a2e;
|
||||||
|
--color-emerald: #10B981;
|
||||||
|
--color-border: #222;
|
||||||
|
--color-border-light: #333;
|
||||||
|
--color-text-primary: #fff;
|
||||||
|
--color-text-secondary: #999;
|
||||||
|
--color-text-muted: #666;
|
||||||
|
|
||||||
|
--color-gold-gradient-start: #D4AF37;
|
||||||
|
--color-gold-gradient-end: #B8860B;
|
||||||
|
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
--font-arabic: "Noto Naskh Arabic", "Traditional Arabic", serif;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #f5f4f0;
|
--nav-floating-bottom: 24px;
|
||||||
--foreground: #111827;
|
|
||||||
--gold: #D4AF37;
|
|
||||||
--emerald: #059669;
|
|
||||||
--nav-height: 64px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color-scheme: light;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: var(--foreground);
|
color: var(--color-text-primary);
|
||||||
background: var(--background);
|
background: var(--color-bg-deep);
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: var(--font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
@@ -42,11 +57,61 @@ input, select, textarea {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scrollbar hidden utility */
|
||||||
|
.scrollbar-none::-webkit-scrollbar { display: none; }
|
||||||
|
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
|
||||||
|
/* Chat scroll */
|
||||||
.chat-scroll {
|
.chat-scroll {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hide scrollbar on category strips */
|
/* Glassmorphism utility */
|
||||||
.scrollbar-none::-webkit-scrollbar { display: none; }
|
.glass {
|
||||||
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
|
background: rgba(17, 17, 24, 0.95);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-light {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gold gradient */
|
||||||
|
.gold-gradient {
|
||||||
|
background: linear-gradient(135deg, #D4AF37, #B8860B);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nur purple gradient */
|
||||||
|
.nur-gradient {
|
||||||
|
background: linear-gradient(135deg, #A78BFA, #7C3AED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gold-purple combined gradient (for center Nur tab) */
|
||||||
|
.gold-purple-gradient {
|
||||||
|
background: linear-gradient(135deg, #D4AF37, #A78BFA);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prayer countdown ring SVG */
|
||||||
|
.prayer-ring {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prayer-ring-bg {
|
||||||
|
fill: none;
|
||||||
|
stroke: rgba(255, 255, 255, 0.08);
|
||||||
|
stroke-width: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prayer-ring-fg {
|
||||||
|
fill: none;
|
||||||
|
stroke: #D4AF37;
|
||||||
|
stroke-width: 4;
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke-dashoffset 1s ease;
|
||||||
|
}
|
||||||
|
|||||||
+17
-8
@@ -1,22 +1,31 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata, Viewport } from 'next'
|
||||||
import { AuthProvider } from '@/lib/AuthContext'
|
import { AuthProvider } from '@/lib/AuthContext'
|
||||||
import BottomNav from '@/components/BottomNav'
|
import BottomNav from '@/components/BottomNav'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Falah — Islamic Lifestyle',
|
title: 'Falah — Your Islamic Lifestyle Companion',
|
||||||
description: 'Nur AI coaching, Halal marketplace, community & wallet',
|
description: 'Nur AI coaching, daily spiritual digest, prayer times, halal marketplace & community',
|
||||||
|
manifest: '/manifest.json',
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: 'black-translucent',
|
||||||
|
title: 'Falah',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: 'device-width',
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: 'cover',
|
||||||
|
themeColor: '#0a0a0f',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang="en" className="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<link rel="apple-touch-icon" href="/icons/icon-512.svg" />
|
||||||
<meta name="theme-color" content="#0a0a0f" />
|
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
|||||||
@@ -2,43 +2,67 @@
|
|||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { Home, Moon, Bot, Repeat2, User } from 'lucide-react'
|
import { House, Brain, User } from 'lucide-react'
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ href: '/home', icon: Home, label: 'Home' },
|
{ href: '/home', icon: House, label: 'Home' },
|
||||||
{ href: '/prayer', icon: Moon, label: 'Prayer' },
|
{ href: '/nur', icon: Brain, label: 'Nur', center: true },
|
||||||
{ href: '/nur', icon: Bot, label: 'Nur', center: true },
|
{ href: '/profile', icon: User, label: 'Profile' },
|
||||||
{ href: '/dhikr', icon: Repeat2, label: 'Dhikr' },
|
|
||||||
{ href: '/profile',icon: User, label: 'Profile' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function BottomNav() {
|
export default function BottomNav() {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
// Hide on auth pages
|
||||||
if (pathname === '/login' || pathname === '/register') return null
|
if (pathname === '/login' || pathname === '/register') return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav className="fixed bottom-0 left-0 right-0 z-50 flex justify-center pointer-events-none">
|
||||||
className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-100"
|
<div
|
||||||
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)', height: 'calc(64px + env(safe-area-inset-bottom, 0px))' }}
|
className="glass rounded-[28px] px-5 py-1.5 flex items-center gap-2 pointer-events-auto"
|
||||||
>
|
style={{
|
||||||
<div className="flex items-stretch h-16 px-2">
|
marginBottom: 'max(env(safe-area-inset-bottom, 0px), 16px)',
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{tabs.map(({ href, icon: Icon, label, center }) => {
|
{tabs.map(({ href, icon: Icon, label, center }) => {
|
||||||
const isActive = pathname === href || (href !== '/home' && pathname.startsWith(href + '/'))
|
const isActive =
|
||||||
|
pathname === href ||
|
||||||
|
(href !== '/home' && pathname.startsWith(href + '/'))
|
||||||
|
|
||||||
if (center) {
|
if (center) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
href={href}
|
href={href}
|
||||||
className="flex-1 flex flex-col items-center justify-center gap-0.5"
|
className="relative flex flex-col items-center active:scale-95 transition-transform"
|
||||||
|
style={{ marginTop: '-20px' }}
|
||||||
>
|
>
|
||||||
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center shadow-sm transition-all active:scale-95 ${
|
<div
|
||||||
isActive
|
className={`w-14 h-14 rounded-full flex items-center justify-center ${
|
||||||
? 'bg-gradient-to-br from-violet-500 to-purple-600'
|
isActive
|
||||||
: 'bg-gradient-to-br from-emerald-500 to-teal-600'
|
? 'gold-purple-gradient shadow-lg shadow-purple-500/20'
|
||||||
}`}>
|
: 'gold-purple-gradient opacity-80'
|
||||||
<Icon size={22} className="text-white" strokeWidth={2} />
|
}`}
|
||||||
|
style={{
|
||||||
|
border: isActive
|
||||||
|
? '2px solid rgba(255,255,255,0.15)'
|
||||||
|
: '2px solid rgba(255,255,255,0.06)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
size={22}
|
||||||
|
className={isActive ? 'text-white' : 'text-white/80'}
|
||||||
|
strokeWidth={isActive ? 2.5 : 1.8}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-semibold mt-0.5 ${
|
||||||
|
isActive ? 'text-gold' : 'text-text-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -47,14 +71,18 @@ export default function BottomNav() {
|
|||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
href={href}
|
href={href}
|
||||||
className="flex-1 flex flex-col items-center justify-center gap-0.5 transition-opacity active:opacity-60"
|
className="flex flex-col items-center justify-center gap-0.5 px-6 py-2 active:scale-95 transition-transform"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
size={22}
|
size={20}
|
||||||
className={isActive ? 'text-emerald-600' : 'text-gray-400'}
|
className={isActive ? 'text-gold' : 'text-text-muted'}
|
||||||
strokeWidth={isActive ? 2.5 : 1.8}
|
strokeWidth={isActive ? 2.5 : 1.8}
|
||||||
/>
|
/>
|
||||||
<span className={`text-[10px] font-medium ${isActive ? 'text-emerald-600' : 'text-gray-400'}`}>
|
<span
|
||||||
|
className={`text-[10px] font-semibold ${
|
||||||
|
isActive ? 'text-gold' : 'text-text-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const WP_API = process.env.NEXT_PUBLIC_WP_API_URL || ''
|
||||||
|
|
||||||
|
export async function fetchAPI<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options?: RequestInit
|
||||||
|
): Promise<T> {
|
||||||
|
const token =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? localStorage.getItem('flh_token')
|
||||||
|
: null
|
||||||
|
|
||||||
|
const res = await fetch(`${WP_API}/${endpoint}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`WordPress API error: ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defaultCache } from '@serwist/next/worker'
|
||||||
|
import type { PrecacheEntry, SerwistGlobalConfig } from 'serwist'
|
||||||
|
import { Serwist } from 'serwist'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface WorkerGlobalScope extends SerwistGlobalConfig {
|
||||||
|
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const self: ServiceWorkerGlobalScope
|
||||||
|
|
||||||
|
const serwist = new Serwist({
|
||||||
|
precacheEntries: self.__SW_MANIFEST,
|
||||||
|
skipWaiting: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
navigationPreload: true,
|
||||||
|
runtimeCaching: defaultCache,
|
||||||
|
})
|
||||||
|
|
||||||
|
serwist.addEventListeners()
|
||||||
Reference in New Issue
Block a user