← 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',
  },
});