export type User = {
  /** basic unless they've paid or an admin has said otherwise. */
  tier?: Tier;
  /** When their tier lapses, if it does. */
  tierExpiresAt?: string;
  /**
   * Whether the person reading follows this one. Worked out on the server
   * for each viewer — deriving it from clicks means someone you already
   * follow shows "Follow" again on a fresh load.
   */
  followedByViewer?: boolean;
  /** Badges granted from the admin panel. */
  awards?: Badge[];
  id: string;
  name: string;
  username: string;
  avatarColor: string; // fallback gradient avatar
  avatarUrl?: string;
  verified?: boolean;
  premium?: boolean; // public — shown as a badge, like `verified`
  badges?: string[]; // small emoji badges shown after the name
  bio?: string;
  admin?: boolean; // display-only; drives the admin avatar aura
  decoration?: string; // Discord-style avatar frame id
  mutualCount?: number; // computed for friend suggestions
};

// Server-side only shape — includes the password hash. Never sent to the client.
export type StoredUser = User & {
  /** A code sent for two-factor sign-in, and when it stops being valid. */
  pendingTwoFactor?: { code: string; expiresAt: string };
  /** Fields managed by the admin user editor. */
  [key: string]: unknown;
  coverUrl?: string; // profile banner
  subscriptionPrice?: number; // monthly, when they accept subscribers
  /** Who invited them, so the bonus can be paid once they're active. */
  referredBy?: string;
  referralCode?: string;
  /** Which permission group they belong to. */
  groupId?: string;
  /** Sent automatically to anyone who messages while this is on. */
  awayMessage?: string;
  awayUntil?: string;
  surname?: string;
  sex?: string;
  relationship?: string;
  country?: string;
  birthday?: string;
  addresses?: string[];
  verification?: {
    idPhoto?: string | null;
    holdingPhoto?: string | null;
    extraInfo?: string;
    status?: "pending" | "approved" | "rejected";
  };
  work?: string;
  location?: string;
  website?: string;
  passwordHash: string;
  email?: string;
  /** Hashed recovery code, for resetting a forgotten password. */
  recoveryHash?: string;
  themeId?: string;
  /** Linked accounts, keyed by provider, holding their id there. */
  social?: Record<string, string>;
  walletBalance: number; // in, the platform's virtual currency — private
  premiumSince?: string;
  isAdmin?: boolean;
  /** Can moderate — reports, approvals — without the full admin panel. */
  isModerator?: boolean;
  /** Which theme they picked. Absent means the site's default. */
  chosenTheme?: string; // private — never exposed via publicUser()
  status?: "active" | "suspended" | "banned"; // private — defaults to "active" when absent
  closeFriendIds?: string[]; // private — user IDs on this user's Close Friends list
};

/** A Group (members post together) or Page (owner broadcasts to followers). */
export type Community = {
  id: string;
  kind: "group" | "page";
  name: string;
  slug: string;
  description: string;
  category: string;
  gradient: string; // cover art
  owner: User;
  memberCount: number;
  privacy: "public" | "private";
  createdAt: string;
  // per-viewer, computed on read
  joined?: boolean;
  isOwner?: boolean;
  coverUrl?: string;
  logoUrl?: string;
  /** Pages only: contact details and opening hours. */
  website?: string;
  email?: string;
  phone?: string;
  address?: string;
  hours?: { day: string; open: string }[]; // uploaded cover photo, overrides the gradient
  requireApproval?: boolean;
  approveMembers?: boolean; // new members wait for the owner // owner must approve posts before they appear
  blockedUserIds?: string[]; // barred from rejoining
};

export type CommunityMember = {
  id: string;
  communityId: string;
  userId: string;
  role: "owner" | "admin" | "member";
  joinedAt: string;
};

export type Comment = {
  id: string;
  postId: string;
  author: User;
  text: string;
  createdAt: string;
  editedAt?: string;
  likes: number;
  liked?: boolean; // computed per-viewer when returned from the API
  parentId?: string; // for nested replies
};

export type ReelComment = {
  id: string;
  reelId: string;
  author: User;
  text: string;
  createdAt: string;
  editedAt?: string;
  likes: number;
  liked?: boolean; // computed per-viewer when returned from the API
  parentId?: string; // set when this is a reply to another comment
};

export type PollOption = {
  id: string;
  text: string;
  voteCount: number;
  icon?: string; // emoji chosen by the author for this option
};

// What's persisted to disk — counts only, no per-viewer state.
export type StoredPost = {
  /** A colour a short post was written on. */
  background?: string;
  /** How they said they were feeling. */
  feeling?: { emoji: string; word: string };
  /** Where they were, shown beside their name. */
  place?: string;
  /** A piece of audio attached to the post. */
  audioUrl?: string;
  /** Who has seen it — one entry per person, not per glance. */
  viewedBy?: string[];
  /** The post this one quotes, if any. A repost is a quote with no text. */
  quotedPostId?: string;
  /** A track chosen in the photo editor, played over the picture. */
  music?: StoryMusic;
  /** People tagged in the photo itself. */
  photoMentions?: string[];
  id: string;
  author: User;
  text: string;
  tag?: string;
  image?: string; // gradient placeholder key (seed data / legacy)
  imageUrl?: string; // real uploaded image, stored as a data URL
  videoUrl?: string; // uploaded clip, served from /media
  /** Set when the post costs money to read. */
  price?: number;
  unlockedBy?: string[];
  /** Per-viewer: whether they've paid for it. */
  unlocked?: boolean;
  createdAt: string;
  editedAt?: string;
  likeCount: number;
  repostCount: number;
  views: number;
  commentCount: number;
  pinned?: boolean;
  spoiler?: boolean;
  hotTake?: boolean;
  premium?: boolean;
  poll?: PollOption[];
  communityId?: string; // set when posted into a group or page
  pollLayout?: "vertical" | "horizontal"; // author's preferred poll orientation
  pendingApproval?: boolean; // held until a community owner approves it
};

// What the API returns — stored fields plus this viewer's own like/save/
// repost state, computed per-request from the session user.
export type Post = StoredPost & {
  /** Who has seen it — one entry per person, not per glance. */
  viewedBy?: string[];
  /** How many people have seen it. */
  views?: number;
  /** How many people have kept this post. */
  saveCount?: number;
  /** The quoted post, ready to render inside this one. */
  quoted?: Post | null;
  liked: boolean;
  saved: boolean;
  reposted: boolean;
  likes: number; // alias of likeCount, kept for the components already using it
  reposts: number; // alias of repostCount
  myVote?: string; // option id this viewer voted for, if any
  // Present only when this post is appearing in a feed/profile *because*
  // someone reposted it, rather than because the viewer follows its author.
  repostedBy?: User;
  repostedAt?: string;
  // True when this post is Premium-only and the viewer isn't the author or
  // a Premium subscriber — text/imageUrl/poll are stripped server-side when
  // this is true, not just hidden client-side.
  locked?: boolean;
};

export type StorySticker = {
  id: string;
  kind?: "emoji" | "location" | "hashtag" | "countdown" | "time" | "mention" | "link"; // defaults to "emoji" if absent, for backward compatibility
  emoji: string; // for kind="emoji": the character itself. For other kinds: unused, kept for the older shape.
  text?: string; // location name / hashtag text
  targetDate?: string; // for kind="countdown"
  x: number; // percent (0-100) from the left
  y: number; // percent (0-100) from the top
  scale?: number; // pinch/drag resize multiplier, 0.5-3 (defaults to 1)
};

export type StoryMusic = {
  trackName: string;
  artistName: string;
  artworkUrl?: string;
  previewUrl: string;
  x?: number; // badge position, percent from left (defaults to top-left area)
  y?: number;
  // How the music badge is displayed on the story — a bare spinning disc,
  // the disc plus track details, or a compact pill.
  style?: "disc" | "full" | "pill";
};

// A highlight stores a self-contained snapshot of each story added to it —
// not just a reference — so it keeps working even if the original story is
// later deleted, matching how real "story highlights" outlive the story's
// normal lifespan.
export type HighlightStory = {
  id: string;
  gradient: string;
  imageUrl?: string;
  drawingUrl?: string;
  stickers?: StorySticker[];
  caption: string;
  textColor?: string;
  fontStyle?: "modern" | "classic" | "signature" | "editorial" | "typewriter" | "neon";
  fontSize?: "sm" | "md" | "lg";
  textScale?: number;
  filter?: "normal" | "clarendon" | "gingham" | "moon" | "lark" | "reyes" | "juno" | "ludwig" | "aden" | "perpetua";
  addedAt: string;
};

export type Highlight = {
  id: string;
  owner: User;
  title: string;
  coverImageUrl?: string; // taken from the first story added, if it has a photo
  stories: HighlightStory[];
  createdAt: string;
};

export type StoryTextBlock = {
  id: string;
  text: string;
  x: number;
  y: number;
  scale: number;
  color: string;
  font: "modern" | "classic" | "signature" | "editorial" | "typewriter" | "neon";
  size: "sm" | "md" | "lg";
};

export type Story = {
  id: string;
  author: User;
  createdAt: string;
  gradient: string; // css gradient for the story card background
  imageUrl?: string; // real uploaded photo — replaces the gradient as the background when present
  drawingUrl?: string; // transparent PNG freehand drawing, layered above the background
  stickers?: StorySticker[];
  caption: string;
  textColor?: string; // hex color for the caption text
  fontStyle?: "modern" | "classic" | "signature" | "editorial" | "typewriter" | "neon";
  fontSize?: "sm" | "md" | "lg";
  textScale?: number; // pinch-to-resize multiplier applied on top of fontSize, 0.5-3
  extraTexts?: StoryTextBlock[]; // additional independently-positioned text blocks
  filter?: "normal" | "clarendon" | "gingham" | "moon" | "lark" | "reyes" | "juno" | "ludwig" | "aden" | "perpetua";
  mentionedUsername?: string;
  linkUrl?: string;
  music?: StoryMusic;
  audience?: "everyone" | "closeFriends";
  frame?: string; // animated ring style shown on the author's story avatar
  seen: boolean;
  likes: number;
};

export type StoredReel = {
  id: string;
  author: User;
  gradient: string;
  caption: string;
  sound: string;
  videoUrl?: string; // uploaded clip (data URL in this build)
  filter?: string; // CSS filter applied to the clip
  description?: string; // shown under the author on the reel page
  musicUrl?: string; // preview audio for the chosen track
  videoVolume?: number; // 0-1 mix for the clip's own audio
  musicVolume?: number; // 0-1 mix for the added track
  likeCount: number;
  commentCount: number;
  repostCount: number;
  views: number;
};

export type Reel = StoredReel & {
  liked: boolean;
  saved: boolean;
  reposted: boolean;
  likes: number;
  comments: number;
  reposts: number;
  authorFollowedByViewer: boolean;
};

export type Follow = {
  id: string;
  followerId: string; // who clicked "Follow"
  followingId: string; // who they followed
  createdAt: string;
};

export type Block = {
  id: string;
  blockerId: string; // who did the blocking
  blockedId: string; // who got blocked
  createdAt: string;
};

// Generic per-user interaction record, reused for post/reel likes, saves,
// and reposts so a like from one account never bleeds into another.
export type Interaction = {
  id: string;
  userId: string;
  targetId: string; // postId or reelId
  createdAt: string;
  // Which emoji the person reacted with. Absent on older records and on
  // interactions that aren't reactions (saves, reposts), which read as a
  // plain like.
  reaction?: "heart" | "thumb" | "laugh" | "party" | "wow" | "sad" | "angry" | "fire";
};

export type PollVote = {
  id: string;
  userId: string;
  postId: string;
  optionId: string;
  createdAt: string;
};

export type WalletTransactionType =
  /** Money returned when a paid RSVP is cancelled. */
  | "event_refund"
  | "deposit"
  | "withdrawal"
  | "tip_sent"
  | "tip_received"
  | "marketplace_purchase"
  | "marketplace_sale"
  | "premium_purchase"
  | "crowdfunding_contribution"
  | "crowdfunding_received"
  | "paid_post_purchase"
  | "paid_post_earning"
  | "subscription_paid"
  | "subscription_earning"
  | "gift_sent"
  | "gift_received"
  | "referral_bonus"
  | "ad_spend"
  | "commission"
  | "membership";

export type WalletTransaction = {
  id: string;
  userId: string;
  type: WalletTransactionType;
  amount: number; // always positive; sign/direction implied by `type`
  description: string;
  otherUser?: User; // the counterparty for tips/marketplace, if any
  createdAt: string;
};

export type MarketplaceListing = {
  id: string;
  seller: User;
  title: string;
  description: string;
  price: number; // in the configured currency
  originalPrice?: number; // set when the seller drops the price
  imageUrl?: string;
  category?: string;
  condition?: "new" | "like-new" | "used" | "fair";
  videoUrl?: string; // short preview clip
  listingKind?: "item" | "vehicle" | "property";
  /** Category-specific fields, kept loose so each form can add its own. */
  details?: Record<string, string | number>;
  location?: string; // town or city — the only part shown publicly
  address?: string; // precise address, used for the map link
  lat?: number;
  lng?: number;
  images?: string[]; // gallery; imageUrl stays the cover
  views?: number;
  savedBy?: string[];
  createdAt: string;
  sold: boolean;
  soldAt?: string;
  buyerId?: string;
  soldExternally?: boolean; // sold off-platform; no feedback is possible
};

export type CrowdfundingCampaign = {
  id: string;
  creator: User;
  title: string;
  description: string;
  goal: number; //
  raised: number; // — sum of all contributions so far
  contributorCount: number;
  imageUrl?: string;
  category?: string;
  createdAt: string;
};

export type NotificationType =
  | "like" | "comment" | "follow" | "repost" | "message" | "tip" | "sale" | "offer"
  /** Sign-in from a new device, and anything else about the account itself. */
  | "security"
  /** Someone wrote your @handle in a post. */
  | "mention"
  /** Someone applied for your job, or you were shortlisted for theirs. */
  | "job_application";

export type AppNotification = {
  /** Set on sale notifications so the buyer can jump to the review form. */
  listingId?: string;
  id: string;
  userId: string; // recipient
  type: NotificationType;
  actor: User;
  postId?: string;
  link?: string; // overrides the default /post/{postId} destination, e.g. a conversation
  text: string;
  createdAt: string;
  read: boolean;
};

export type PublicProfile = User & {
  awards?: Badge[];
  awayMessage?: string;
  awayUntil?: string;
  /** Settings fields — private to the account holder. */
  email?: string;
  surname?: string;
  sex?: string;
  relationship?: string;
  country?: string;
  birthday?: string;
  addresses?: string[];
  premium?: boolean;
  coverUrl?: string;
  work?: string;
  location?: string;
  website?: string;
  joinedAt?: string;
  /** Mutual follows, shown as "Friends" on the profile. */
  friendCount?: number;
  followerCount: number;
  followingCount: number;
  isFollowing: boolean;
  /** Whether they point back. On a friends site this is what separates a
   *  request you sent from an actual friendship. */
  followsMe?: boolean;
  isSelf: boolean;
  isBlocked: boolean;
  postCount: number;
};

export type Conversation = {
  /** Ids of people who pinned this chat. */
  pinnedBy?: string[];
  /** Set when the chat began from a listing, so it shows under Market. */
  listingId?: string;
  id: string;
  // Two ids for a direct chat; more for a group.
  participantIds: string[];
  isGroup?: boolean;
  name?: string;
  description?: string;
  imageUrl?: string;
  ownerId?: string;
  createdAt: string;
  hiddenBy?: string[]; // participant ids who've "deleted" this from their own inbox
};

export type DirectMessage = {
  /** Sent by the away auto-reply rather than typed. */
  automatic?: boolean;
  /** A recorded voice message. */
  audioUrl?: string;
  /** Set when the message is a reply to a story, so the chat can show it. */
  storyRef?: {
    id: string;
    imageUrl?: string;
    gradient?: string;
    caption?: string;
    authorName: string;
  };
  id: string;
  conversationId: string;
  senderId: string;
  text: string;
  createdAt: string;
  read: boolean;
  imageUrl?: string; // data URL for photo messages
  replyToId?: string; // quoted message, WhatsApp style
};

// What the conversations-list API returns — the conversation plus the other
// participant's profile and a preview of the last message.
export type ConversationSummary = {
  /** Kept at the top of the list. */
  pinned?: boolean;
  listingId?: string;
  id: string;
  otherUser: User;
  isGroup?: boolean;
  name?: string;
  description?: string;
  imageUrl?: string;
  ownerId?: string;
  members?: User[];
  lastMessage?: { text: string; createdAt: string; senderId: string };
  unreadCount: number;
};

export type ReportTargetType = "post" | "comment" | "reel" | "user";

export type Report = {
  id: string;
  reporter: User;
  targetType: ReportTargetType;
  targetId: string;
  reason: string;
  // A short snapshot of what was reported, captured at report time, so the
  // queue still shows something meaningful even if the content is later
  // edited or deleted.
  contentPreview: string;
  contentAuthor?: User;
  premium?: boolean;
  activated?: boolean;
  status: "pending" | "resolved" | "dismissed";
  createdAt: string;
  resolvedAt?: string;
};

// Admin-only view of a user — includes fields that never appear in the
// public User type (status, admin flag, wallet balance, real email-less
// identifiers), for the admin user-management table.
export type AdminUserSummary = User & {
  /** False while an admin has yet to approve the account. */
  activated?: boolean;
  status: "active" | "suspended" | "banned";
  isAdmin: boolean;
  walletBalance: number;
  postCount: number;
  followerCount: number;
  joinedOrder: number; // seed/signup order, used as a stand-in for a join date
};

export type AdminStats = {
  totalUsers: number;
  totalPosts: number;
  totalReels: number;
  totalComments: number;
  totalListings: number;
  activeListings: number;
  totalCurrency: number; // sum of every wallet balance, in
  premiumUsers: number;
  pendingReports: number;
  postsPerDay: { date: string; count: number }[]; // last 14 days
};

export type NewsArticle = {
  id: string;
  title: string;
  category: string;
  imageUrl?: string;
  views: number;
  publishedAt: string;
};

export type SellerReview = {
  id: string;
  sellerId: string;
  author: User;
  rating: number; // 1-5
  text: string;
  createdAt: string;
  listingId?: string; // the purchase it relates to, when known
};

export type MarketplaceCategory = {
  id: string;
  label: string;
  emoji: string;
  tint: string; // chip colour key
  enabled: boolean;
};

export type MarketplaceSettings = {
  enabled: boolean;
  categories: MarketplaceCategory[];
  currency: string;
  minPrice: number;
  maxPrice: number;
  requireApproval: boolean; // listings wait for an admin before going live
  allowVideo: boolean;
  maxVideoMb: number;
  maxPhotos: number;
  allowOffers: boolean;
  commissionPercent: number;
  soldVisibleDays: number; // how long a sold listing keeps showing
  distanceUnit: "km" | "mi";
  bannerTitle: string;
  bannerSubtitle: string;
};


/** An award an admin defines, then grants to people. */
export type Badge = {
  id: string;
  name: string;
  description?: string;
  icon: string; // lucide icon name
  /** Two colours, so badges read as a gradient pill rather than flat. */
  color: string;
  color2?: string;
  /** Awarded automatically once the holder passes this threshold. */
  rule?: {
    metric:
      | "posts"
      | "followers"
      | "following"
      | "comments"
      | "likesReceived"
      | "listings"
      | "sales"
      | "reviews"
      | "accountAgeDays"
      | "communities";
    atLeast: number;
  };
  /** Icon only, no coloured pill behind it. */
  plain?: boolean;
  order: number;
  active: boolean;
  createdAt: string;
};

export type UserBadge = {
  id: string;
  badgeId: string;
  userId: string;
  awardedAt: string;
  /** Set when a rule granted it rather than an admin. */
  automatic?: boolean;
};


/** Site-wide settings an admin can change without touching code. */
export type SiteSettings = {
  /** The settings pages add fields beyond the ones named here. */
  [key: string]: unknown;
  // Identity
  siteName: string;
  tagline: string;
  keywords?: string;
  logoUrl?: string;
  accentColor: string;
  seoTitle?: string;
  seoDescription?: string;
  seoKeywords?: string;

  // Registration
  signupsOpen: boolean;
  requireEmailVerification: boolean;
  minimumAge: number;

  // Limits
  maxPostLength: number;
  maxImageMb: number;
  maxVideoMb: number;
  maxVideoSeconds: number;
  maxBioLength: number;

  // Features
  features: {
    reels: boolean;
    stories: boolean;
    communities: boolean;
    marketplace: boolean;
    crowdfunding: boolean;
    match: boolean;
    news: boolean;
    leaderboard: boolean;
    wallet: boolean;
  };

  // Moderation
  bannedWords: string[];
  autoHideReported: number; // reports before a post is hidden; 0 = never
  requirePostApproval: boolean;

  // Announcement banner
  announcement: { text: string; active: boolean; tone: "info" | "warn" | "success" };

  // Premium
  premiumPriceUsd: number;
  premiumPerks: string[];
  /** Store links, empty until the apps exist. */
  appLinks: { ios: string; android: string };
};


/** A creator cashing out. An admin approves or declines it. */
export type WithdrawalRequest = {
  id: string;
  userId: string;
  amount: number;
  method: "paypal" | "bank" | "crypto";
  destination: string; // email, IBAN or address — shown only to admins
  status: "pending" | "approved" | "declined" | "paid";
  note?: string;
  requestedAt: string;
  resolvedAt?: string;
};

/** An advert someone paid to run. */
export type AdCampaign = {
  id: string;
  advertiser: User;
  title: string;
  body: string;
  imageUrl?: string;
  linkUrl: string;
  /** Charged per view or per click. */
  pricing: "view" | "click";
  budget: number;
  spent: number;
  views: number;
  clicks: number;
  status: "pending" | "running" | "paused" | "finished" | "rejected";
  createdAt: string;
};

/** Someone subscribing to a creator's paid content. */
export type Subscription = {
  id: string;
  creatorId: string;
  subscriberId: string;
  monthlyPrice: number;
  startedAt: string;
  renewsAt: string;
  active: boolean;
};

/** A paid gift sent to a creator. */
export type Gift = {
  id: string;
  name: string;
  emoji: string;
  price: number;
  active: boolean;
};

export type MoneySettings = {
  enabled: boolean;
  /** Percentage the platform keeps on sales and tips. */
  commissionPercent: number;
  minWithdrawal: number;
  withdrawalMethods: ("paypal" | "bank" | "crypto")[];
  allowPaidPosts: boolean;
  maxPaidPostPrice: number;
  allowSubscriptions: boolean;
  maxSubscriptionPrice: number;
  allowTransfers: boolean;
  allowGifts: boolean;
  referralBonus: number;
  ads: {
    enabled: boolean;
    requireApproval: boolean;
    costPerView: number;
    costPerClick: number;
  };
};


/** Something happening at a time and place, that people can RSVP to. */
export type Event = {
  id: string;
  slug: string;
  title: string;
  description: string;
  host: User;
  coverUrl?: string;
  gradient: string;
  category: string;
  /** ISO timestamps. */
  startsAt: string;
  endsAt?: string;
  /** Either a place or a link, depending on how it's held. */
  online: boolean;
  venue?: string;
  address?: string;
  lat?: number;
  lng?: number;
  meetingUrl?: string;
  /** Free when absent. */
  price?: number;
  capacity?: number;
  privacy: "public" | "private";
  createdAt: string;
  cancelledAt?: string;

  // Per-viewer, computed on read
  going?: number;
  interested?: number;
  myRsvp?: "going" | "interested" | null;
  isHost?: boolean;
};

export type EventRsvp = {
  id: string;
  eventId: string;
  userId: string;
  status: "going" | "interested";
  paid?: boolean;
  createdAt: string;
};


/** Terms, privacy, about — pages an admin writes without touching code. */
export type StaticPage = {
  id: string;
  slug: string;
  title: string;
  body: string;
  /** Shown in the footer when true. */
  inFooter: boolean;
  published: boolean;
  updatedAt: string;
};

/** Words, emails and addresses that can't be used to sign up. */
export type Blacklist = {
  usernames: string[];
  emails: string[];
  domains: string[];
};

/** A named set of permissions, applied to members of the group. */
export type UserGroup = {
  id: string;
  name: string;
  colour: string;
  /** Everything this group is allowed to do. */
  can: {
    post: boolean;
    comment: boolean;
    message: boolean;
    sell: boolean;
    createEvents: boolean;
    createCommunities: boolean;
    advertise: boolean;
    withdraw: boolean;
  };
  /** Applied to new accounts when true. */
  isDefault: boolean;
};


/** SMTP details so the site can actually send email. */
export type MailSettings = {
  enabled: boolean;
  host: string;
  port: number;
  secure: boolean;
  username: string;
  password: string;
  fromName: string;
  fromAddress: string;
  /** Which messages get sent. */
  send: {
    welcome: boolean;
    passwordReset: boolean;
    verifyAddress: boolean;
    newFollower: boolean;
    newMessage: boolean;
    marketplaceSale: boolean;
  };
};


/**
 * Where uploads are kept. Local disk works for one server; object storage
 * is what you need once there's more than one, or a CDN in front.
 */
export type StorageSettings = {
  driver: "local" | "s3";
  bucket: string;
  region: string;
  /** Set for S3-compatible services (Backblaze, Wasabi, DigitalOcean, R2). */
  endpoint: string;
  accessKeyId: string;
  secretAccessKey: string;
  /** Serve from a CDN instead of the bucket URL, when there is one. */
  publicUrl: string;
  forcePathStyle: boolean;
};


/** Sign in with an existing account elsewhere. */
export type SocialLoginSettings = {
  google: { enabled: boolean; clientId: string; clientSecret: string };
  facebook: { enabled: boolean; appId: string; appSecret: string };
  github: { enabled: boolean; clientId: string; clientSecret: string };
  /** Where the provider sends people back to, e.g. https://yoursite.com */
  siteUrl: string;
};


/** Stops automated signups and spam. */
export type CaptchaSettings = {
  provider: "none" | "recaptcha" | "turnstile" | "hcaptcha";
  siteKey: string;
  secretKey: string;
  /** Where it's required. */
  on: {
    signup: boolean;
    login: boolean;
    post: boolean;
    contact: boolean;
  };
};


/** A named look for the site. Admins can sell them; people pick one. */
export type Theme = {
  id: string;
  name: string;
  description: string;
  /** Shown in the picker. */
  preview: { bg: string; card: string; accent: string; text: string };
  /** CSS custom properties applied when this theme is active. */
  tokens: Record<string, string>;
  /** 0 means free. Anything higher is bought from the wallet. */
  price: number;
  /** Everyone gets this one, and it can't be deleted or sold. */
  isDefault: boolean;
  /** Hidden themes stay bought but aren't offered to anyone new. */
  active: boolean;
  order: number;
};

/** Someone owning a paid theme. */
export type ThemePurchase = {
  id: string;
  themeId: string;
  userId: string;
  paid: number;
  boughtAt: string;
};


/** Taking real money in, and paying it out. */
export type GatewaySettings = {
  /** Shown against amounts. The wallet is denominated in this. */
  currency: string;
  currencySymbol: string;

  stripe: {
    enabled: boolean;
    publishableKey: string;
    secretKey: string;
    /** Verifies that webhooks genuinely came from Stripe. */
    webhookSecret: string;
    testMode: boolean;
  };

  paypal: {
    enabled: boolean;
    clientId: string;
    clientSecret: string;
    sandbox: boolean;
  };

  /** What people can top up in one go. */
  minTopUp: number;
  maxTopUp: number;
};

/** A real-money payment, tracked from start to finish. */
export type Payment = {
  id: string;
  userId: string;
  provider:
    | "stripe" | "paypal" | "razorpay" | "paystack" | "flutterwave"
    | "mercadopago" | "authorizeNet" | "coinbase" | "coinpayments";
  /** The provider's own reference, for reconciling. */
  externalId: string;
  amount: number;
  currency: string;
  purpose: "topup" | "premium";
  status: "pending" | "paid" | "failed" | "refunded";
  createdAt: string;
  completedAt?: string;
};


/**
 * One entry in a managed list — a marketplace category, a country, a gender,
 * a report reason. Keeping them in one shape means one screen manages all of
 * them rather than each being hardcoded in several files.
 */
export type TaxonomyItem = {
  id: string;
  label: string;
  /** Emoji or short mark shown beside the label, where the UI uses one. */
  icon?: string;
  /** Only some lists use this — currencies need a symbol, countries a code. */
  value?: string;
  order: number;
  active: boolean;
};

export type TaxonomyName =
  | "marketplaceCategories"
  | "communityCategories"
  | "eventCategories"
  | "postTags"
  | "reportReasons"
  | "genders"
  | "relationships"
  | "countries"
  | "currencies"
  | "reactions";


/** A long-form article, separate from the feed. */
export type BlogPost = {
  id: string;
  slug: string;
  title: string;
  excerpt: string;
  body: string;
  author: User;
  category: string;
  coverUrl?: string;
  gradient: string;
  status: "draft" | "published";
  views: number;
  publishedAt?: string;
  createdAt: string;
};


/** A one-use code letting someone join an invitation-only site. */
export type Invitation = {
  code: string;
  invitedBy: string;
  /** Set once someone signs up with it. */
  usedBy?: string;
  usedAt?: string;
  createdAt: string;
  expiresAt: string;
};


/** An offer to buy a listing for less than the asking price. */
/* ------------------------------ Jobs & Offers --------------------------- */

/**
 * Jobs and Offers, carried over from Sngine.
 *
 * The fields are Sngine's, because a site moving across brings its listings
 * with it and anything dropped here is data lost in the move. What has
 * changed is the shape: Sngine spreads a job over posts + posts_jobs and
 * numbers everything, while these are single records with the string ids
 * the rest of this site already uses. The import converts as it copies.
 */

export type JobType =
  | "full_time"
  | "part_time"
  | "contract"
  | "internship"
  | "volunteer";

export type PayPer = "per_hour" | "per_day" | "per_week" | "per_month" | "per_year";

/**
 * One screening question on an application form.
 *
 * Three at most, which is Sngine's limit and a sensible one — a form longer
 * than that is one people abandon.
 */
export type JobQuestion = {
  type: "text" | "multiple_choice";
  title: string;
  /** Only for multiple_choice; ignored otherwise. */
  choices?: string[];
};

export type Job = {
  id: string;
  poster: User;
  title: string;
  description: string;
  category?: string;
  location?: string;
  /** A range, either end optional — plenty of ads give only a minimum. */
  salaryMin?: number;
  salaryMax?: number;
  currency?: string;
  payPer?: PayPer;
  type?: JobType;
  questions?: JobQuestion[];
  coverImage?: string;
  /**
   * A closed job stays readable and stops taking applications, rather than
   * disappearing — people who applied still want to find it.
   */
  open: boolean;
  views?: number;
  savedBy?: string[];
  createdAt: string;
};

export type JobApplication = {
  id: string;
  jobId: string;
  applicant: User;
  name: string;
  email: string;
  phone?: string;
  location?: string;
  /** Most recent role, as Sngine's form asks for it. */
  workPlace?: string;
  workPosition?: string;
  workDescription?: string;
  workFrom?: string;
  workTo?: string;
  /** Still there, so workTo is left empty. */
  workNow?: boolean;
  /** One answer per question, in the order the job asked them. */
  answers?: string[];
  cvUrl?: string;
  status: "new" | "shortlisted" | "declined";
  createdAt: string;
};

/**
 * The four kinds of deal, matching the four Sngine stores:
 *
 *   percent        20% off
 *   amount         £5 off
 *   buy_x_get_y    buy 2 get 1 free
 *   spend_x_get_y  spend £50 get £10 off
 */
export type DiscountType = "percent" | "amount" | "buy_x_get_y" | "spend_x_get_y";

export type Offer = {
  id: string;
  poster: User;
  title: string;
  description: string;
  category?: string;
  discountType: DiscountType;
  /** percent */
  discountPercent?: number;
  /** amount */
  discountAmount?: number;
  /** buy_x_get_y */
  buyX?: number;
  getY?: number;
  /** spend_x_get_y */
  spendX?: number;
  amountY?: number;
  currency?: string;
  /** What the thing costs before the deal, when the poster says. */
  price?: number;
  /** After this the offer reads as expired rather than vanishing. */
  endsAt?: string;
  thumbnail?: string;
  location?: string;
  views?: number;
  savedBy?: string[];
  createdAt: string;
};

/**
 * A price offer on a marketplace listing — "would you take £40 for it".
 *
 * Named for what it is, because "Offer" now belongs to the Offers module:
 * the discounts and deals a shop posts, which is a different thing entirely.
 */
export type ListingOffer = {
  id: string;
  listingId: string;
  buyerId: string;
  amount: number;
  message?: string;
  status: "pending" | "accepted" | "declined" | "paid";
  createdAt: string;
  decidedAt?: string;
};


/** A message sent through the contact form. */
export type ContactMessage = {
  id: string;
  name: string;
  email: string;
  subject: string;
  message: string;
  /** Set when the sender was signed in. */
  userId?: string;
  handled: boolean;
  createdAt: string;
};


/** A support request and the conversation about it. */
export type SupportTicket = {
  id: string;
  userId: string;
  subject: string;
  category: string;
  status: "open" | "answered" | "closed";
  /** Both sides of the conversation, oldest first. */
  replies: {
    id: string;
    authorId: string;
    fromStaff: boolean;
    text: string;
    createdAt: string;
  }[];
  createdAt: string;
  updatedAt: string;
};


/** A category under a module — marketplace, blogs, groups and so on. */
export type Category = {
  id: string;
  module: string;
  name: string;
  slug: string;
  enabled: boolean;
  /** Set when it sits under another one. */
  parentId?: string;
  order: number;
};


/** What someone has paid for, or been given. */
export type Tier = "basic" | "pro" | "ultra";

/** How they came to have it — it decides whether it can expire. */
export type TierSource = "subscription" | "admin";

export type Membership = {
  id: string;
  userId: string;
  tier: Tier;
  source: TierSource;
  startedAt: string;
  /** When it lapses. Absent means it doesn't — an admin grant, usually. */
  expiresAt?: string;
  /** What was charged, so the earnings list has something to show. */
  paid?: number;
  cancelledAt?: string;
};


/** A banner across the top of the site. */
export type Announcement = {
  id: string;
  text: string;
  tone: "info" | "warning" | "success";
  link?: string;
  dismissible: boolean;
  /** When it starts and stops showing. Absent means straight away, forever. */
  startsAt?: string;
  endsAt?: string;
  createdAt: string;
};
