← back to CelebritySignatures
apps/mobile/screens/MuralsScreen.tsx
533 lines
// MuralsScreen.tsx
//
// App Store compliance note (Guideline 3.1.3(e)):
// Mural prints are PHYSICAL GOODS — IAP-exempt under 3.1.3(e). Checkout is
// Stripe-hosted (redirect-based), so the full purchase flow opens the website
// via onOpenWeb(). Do NOT implement native card payment here; that would pull
// the transaction under App Store billing rules unnecessarily.
//
// Usage:
// <MuralsScreen onOpenWeb={(url) => Linking.openURL(url)} />
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
ActivityIndicator,
FlatList,
KeyboardAvoidingView,
ListRenderItemInfo,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
TouchableWithoutFeedback,
View,
} from 'react-native';
import * as Haptics from 'expo-haptics';
import { api, Mural, MuralsCatalog, WEB_BASE } from '../api';
import { GLASS, GlassButton, GlassPanel, GlassPill } from '../ui/glass';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type QuoteState =
| { phase: 'idle' }
| { phase: 'open'; name: string; email: string; note: string; busy: boolean; error: string | null }
| { phase: 'done' };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const MAX_ROSTER_VISIBLE = 4;
function rosterLine(roster: string[]): string {
if (roster.length <= MAX_ROSTER_VISIBLE) {
return `Featuring: ${roster.join(', ')}`;
}
const shown = roster.slice(0, MAX_ROSTER_VISIBLE).join(', ');
const extra = roster.length - MAX_ROSTER_VISIBLE;
return `Featuring: ${shown} +${extra} more`;
}
function looslyValidEmail(v: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim());
}
// ---------------------------------------------------------------------------
// Inline inquiry form (expands inside the card)
// ---------------------------------------------------------------------------
function InquiryForm({ mural, onClose }: { mural: Mural; onClose: () => void }) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const emailRef = useRef<TextInput>(null);
const noteRef = useRef<TextInput>(null);
const handleSubmit = useCallback(async () => {
if (!name.trim()) { setError('Name is required.'); return; }
if (!looslyValidEmail(email)) { setError('Enter a valid email address.'); return; }
setError(null);
setBusy(true);
try {
const res = await api.muralOrder({ name: name.trim(), email: email.trim(), mural: mural.code, note: note.trim() || undefined });
if (res.ok) {
setDone(true);
} else {
setError('Something went wrong — please try again.');
}
} catch {
setError('Network error — please try again.');
} finally {
setBusy(false);
}
}, [name, email, note, mural.code]);
if (done) {
return (
<View style={styles.formDone}>
<Text style={styles.formDoneText}>Request sent ✓</Text>
<Text style={styles.formDoneSubtext}>We'll be in touch about your {mural.title} print.</Text>
<Pressable onPress={onClose} style={styles.formDismissBtn}>
<Text style={styles.formDismissText}>Close</Text>
</Pressable>
</View>
);
}
return (
<View style={styles.form}>
<Text style={styles.formHeading}>Request a quote</Text>
<Text style={styles.fieldLabel}>Your name</Text>
<TextInput
style={styles.input}
placeholder="Full name"
placeholderTextColor={GLASS.textDim}
value={name}
onChangeText={setName}
autoCapitalize="words"
returnKeyType="next"
onSubmitEditing={() => emailRef.current?.focus()}
blurOnSubmit={false}
editable={!busy}
/>
<Text style={styles.fieldLabel}>Email</Text>
<TextInput
ref={emailRef}
style={styles.input}
placeholder="you@example.com"
placeholderTextColor={GLASS.textDim}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
returnKeyType="next"
onSubmitEditing={() => noteRef.current?.focus()}
blurOnSubmit={false}
editable={!busy}
/>
<Text style={styles.fieldLabel}>Note (optional)</Text>
<TextInput
ref={noteRef}
style={[styles.input, styles.inputMulti]}
placeholder="Room dimensions, timeline, special requests…"
placeholderTextColor={GLASS.textDim}
value={note}
onChangeText={setNote}
multiline
numberOfLines={3}
returnKeyType="done"
editable={!busy}
/>
{error !== null && <Text style={styles.errorText}>{error}</Text>}
<View style={styles.formActions}>
<View style={styles.formActionBtn}>
<GlassButton
label="Submit"
solid
busy={busy}
onPress={handleSubmit}
/>
</View>
<View style={styles.formActionBtn}>
<GlassButton
label="Cancel"
onPress={onClose}
disabled={busy}
/>
</View>
</View>
</View>
);
}
// ---------------------------------------------------------------------------
// Individual mural card
// ---------------------------------------------------------------------------
function MuralCard({
mural,
onOrderWeb,
}: {
mural: Mural;
onOrderWeb: (url: string) => void;
}) {
const [quoteOpen, setQuoteOpen] = useState(false);
const handleOrder = useCallback(async () => {
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onOrderWeb(`${WEB_BASE}/murals?mural=${encodeURIComponent(mural.code)}`);
}, [mural.code, onOrderWeb]);
const handleQuoteToggle = useCallback(() => {
setQuoteOpen((v) => !v);
}, []);
const handleQuoteClose = useCallback(() => {
setQuoteOpen(false);
}, []);
return (
<GlassPanel style={styles.card}>
{/* Header row */}
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}>{mural.title}</Text>
{mural.sigs !== undefined && (
<GlassPill tintColor={GLASS.accent}>
<Text style={styles.pillText}>{mural.sigs} signature{mural.sigs !== 1 ? 's' : ''}</Text>
</GlassPill>
)}
</View>
{/* Blurb */}
{mural.blurb !== undefined && (
<Text style={styles.cardBlurb}>{mural.blurb}</Text>
)}
{/* Roster */}
{mural.roster !== undefined && mural.roster.length > 0 && (
<Text style={styles.cardRoster} numberOfLines={2}>
{rosterLine(mural.roster)}
</Text>
)}
{/* Action buttons */}
{!quoteOpen && (
<View style={styles.cardActions}>
<View style={styles.cardActionBtn}>
<GlassButton
label="Order print (on web)"
solid
onPress={handleOrder}
/>
</View>
<View style={styles.cardActionBtn}>
<GlassButton
label="Request a quote"
onPress={handleQuoteToggle}
/>
</View>
</View>
)}
{/* Inline inquiry form */}
{quoteOpen && (
<InquiryForm mural={mural} onClose={handleQuoteClose} />
)}
</GlassPanel>
);
}
// ---------------------------------------------------------------------------
// Main screen
// ---------------------------------------------------------------------------
export default function MuralsScreen({ onOpenWeb }: { onOpenWeb: (url: string) => void }) {
const [catalog, setCatalog] = useState<MuralsCatalog | null>(null);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const loadCatalog = useCallback(async () => {
setLoading(true);
setFetchError(null);
try {
const data = await api.murals();
setCatalog(data);
} catch (e) {
setFetchError(e instanceof Error ? e.message : 'Failed to load murals.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadCatalog(); }, [loadCatalog]);
const renderItem = useCallback(
({ item }: ListRenderItemInfo<Mural>) => (
<MuralCard mural={item} onOrderWeb={onOpenWeb} />
),
[onOpenWeb],
);
const keyExtractor = useCallback((item: Mural) => item.code, []);
// Caption derived from catalog metadata
const caption = catalog !== null
? `From $${catalog.pricePerSqFt}/sq ft · ${catalog.size.widthFt}×${catalog.size.heightFt} ft standard`
: null;
// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------
if (loading) {
return (
<View style={styles.centeredFill}>
<ActivityIndicator color={GLASS.accent} size="large" />
<Text style={styles.loadingText}>Loading murals…</Text>
</View>
);
}
// ---------------------------------------------------------------------------
// Error + retry
// ---------------------------------------------------------------------------
if (fetchError !== null || catalog === null) {
return (
<View style={styles.centeredFill}>
<Text style={styles.errorHeading}>Couldn't load murals</Text>
<Text style={styles.errorBody}>{fetchError ?? 'Unknown error'}</Text>
<View style={styles.retryBtn}>
<GlassButton label="Retry" solid onPress={loadCatalog} />
</View>
</View>
);
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<KeyboardAvoidingView
style={styles.root}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 88 : 0}
>
<FlatList<Mural>
data={catalog.murals}
keyExtractor={keyExtractor}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
ListHeaderComponent={
<View style={styles.listHeader}>
<Text style={styles.headerTitle}>Signature Murals</Text>
{caption !== null && (
<Text style={styles.headerCaption}>{caption}</Text>
)}
</View>
}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
/>
</KeyboardAvoidingView>
);
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const styles = StyleSheet.create({
root: {
flex: 1,
},
// Full-screen states
centeredFill: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 32,
gap: 12,
},
loadingText: {
color: GLASS.textDim,
fontSize: 15,
marginTop: 12,
},
errorHeading: {
color: GLASS.accentSoft,
fontSize: 18,
fontWeight: '700',
textAlign: 'center',
},
errorBody: {
color: GLASS.textDim,
fontSize: 14,
textAlign: 'center',
lineHeight: 20,
},
retryBtn: {
width: 180,
marginTop: 8,
},
// List layout
listContent: {
paddingHorizontal: 16,
paddingBottom: 48,
gap: 14,
},
listHeader: {
paddingTop: 20,
paddingBottom: 8,
gap: 4,
},
headerTitle: {
color: GLASS.accentSoft,
fontSize: 26,
fontWeight: '800',
letterSpacing: 0.3,
},
headerCaption: {
color: GLASS.textDim,
fontSize: 13,
letterSpacing: 0.1,
},
// Card
card: {
// GlassPanel adds its own border + borderRadius; we just need margin spacing
},
cardHeader: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 10,
marginBottom: 6,
},
cardTitle: {
flex: 1,
color: GLASS.accentSoft,
fontSize: 17,
fontWeight: '700',
lineHeight: 22,
},
pillText: {
color: GLASS.ink,
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.3,
},
cardBlurb: {
color: GLASS.textDim,
fontSize: 13,
lineHeight: 19,
marginBottom: 6,
},
cardRoster: {
color: GLASS.textDim,
fontSize: 12,
lineHeight: 17,
fontStyle: 'italic',
marginBottom: 10,
},
// Card actions
cardActions: {
marginTop: 10,
gap: 8,
},
cardActionBtn: {
// GlassButton is full-width inside its parent by default
},
// Inquiry form
form: {
marginTop: 12,
gap: 4,
},
formHeading: {
color: GLASS.accentSoft,
fontSize: 15,
fontWeight: '700',
marginBottom: 8,
},
fieldLabel: {
color: GLASS.textDim,
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.5,
textTransform: 'uppercase',
marginTop: 8,
marginBottom: 4,
},
input: {
backgroundColor: '#ffffff',
borderRadius: 10,
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(20,18,16,0.14)',
paddingHorizontal: 13,
paddingVertical: Platform.OS === 'ios' ? 12 : 9,
color: GLASS.accentSoft,
fontSize: 15,
},
inputMulti: {
minHeight: 72,
textAlignVertical: 'top',
paddingTop: 10,
},
errorText: {
color: '#e07070',
fontSize: 12,
marginTop: 6,
lineHeight: 16,
},
formActions: {
marginTop: 14,
gap: 8,
},
formActionBtn: {},
// Done state
formDone: {
marginTop: 12,
alignItems: 'center',
gap: 6,
paddingVertical: 8,
},
formDoneText: {
color: GLASS.accent,
fontSize: 18,
fontWeight: '800',
},
formDoneSubtext: {
color: GLASS.textDim,
fontSize: 13,
textAlign: 'center',
lineHeight: 18,
},
formDismissBtn: {
marginTop: 8,
paddingVertical: 6,
paddingHorizontal: 20,
},
formDismissText: {
color: GLASS.accentSoft,
fontSize: 14,
fontWeight: '600',
},
});