Compare commits

..

7 commits

Author SHA1 Message Date
c7cf1d2111 Update site copy to reflect that Alt Styles are released
Announcements babyyy!
2024-02-01 06:58:54 -08:00
c13c6e7bd8 Remove the isLazy focus-management hack from PosePicker
Now that we're tracking tab state ourselves, it's pretty easy to just
pass the `initialFocusRef` to the right place instead of to both!

This helps switching between the tabs feel a lot smoother, because we
don't have to re-render and fade-in all the poses again.
2024-02-01 06:53:32 -08:00
00bc9c3bf7 Use hourglass icon for Retired UCs
The sunglasses communicated too much legitimacy lmao
2024-02-01 06:48:40 -08:00
e2d8a86f79 Add a warning that UCs are retired now
I'm keeping it in the same place for now rather than trying to fold it
into Styles, because I think that's net-less-confusing (since Styles
work pretty differently, e.g. different color requirements), and
certainly less work either way lol!
2024-02-01 06:43:14 -08:00
3a76dbb368 Show Alt Styles to all users in the outfit editor!
I think we're ready, turning off the Support-only gate!
2024-02-01 06:01:04 -08:00
453d6783c4 Move the style and state params earlier in the outfit URL
Idk I just think it's nice for them to be next to pose and such!
2024-02-01 06:00:39 -08:00
c60e222faa Add Alt Style support to outfit saving
Pretty straightforward, just add the field to the record, and wire it
all up! I'm glad this seemed to work out pretty well all-in-all 😅
2024-02-01 05:55:19 -08:00
11 changed files with 117 additions and 55 deletions

View file

@ -115,7 +115,7 @@ class OutfitsController < ApplicationController
def outfit_params
params.require(:outfit).permit(
:name, :starred, item_ids: {worn: [], closeted: []},
:name, :starred, :alt_style_id, item_ids: {worn: [], closeted: []},
biology: [:species_id, :color_id, :pose])
end

View file

@ -23,7 +23,7 @@ import {
useToast,
useToken,
} from "@chakra-ui/react";
import { ChevronDownIcon } from "@chakra-ui/icons";
import { ChevronDownIcon, WarningTwoIcon } from "@chakra-ui/icons";
import { loadable } from "../util";
import { petAppearanceFragment } from "../components/useOutfitAppearance";
@ -42,6 +42,7 @@ import twemojiSunglasses from "../images/twemoji/sunglasses.svg";
import twemojiQuestion from "../images/twemoji/question.svg";
import twemojiMasc from "../images/twemoji/masc.svg";
import twemojiFem from "../images/twemoji/fem.svg";
import twemojiHourglass from "../images/twemoji/hourglass.svg";
const PosePickerSupport = loadable(() => import("./support/PosePickerSupport"));
@ -218,18 +219,11 @@ function PosePicker({
gap="2"
index={tabIndex}
onChange={setTabIndex}
// HACK: To only apply `initialFocusRef` to the selected input
// in the *active* tab, we just use `isLazy` to only *render*
// the active tab. We could also watch the tab state and set
// the ref accordingly!
isLazy
>
<SupportOnly>
<TabList paddingX="2" paddingY="0">
<Tab width="50%">Expressions</Tab>
<Tab width="50%">Styles</Tab>
</TabList>
</SupportOnly>
<TabList paddingX="2" paddingY="0">
<Tab width="50%">Expressions</Tab>
<Tab width="50%">Styles</Tab>
</TabList>
<TabPanels position="relative">
<TabPanel paddingX="4" paddingY="0">
{isInSupportMode ? (
@ -238,7 +232,9 @@ function PosePicker({
colorId={colorId}
pose={pose}
appearanceId={appearanceId}
initialFocusRef={initialFocusRef}
initialFocusRef={
tabIndex === 0 ? initialFocusRef : null
}
dispatchToOutfit={dispatchToOutfit}
/>
) : (
@ -246,7 +242,9 @@ function PosePicker({
<PosePickerTable
poseInfos={poseInfos}
onChange={onChangePose}
initialFocusRef={initialFocusRef}
initialFocusRef={
tabIndex === 0 ? initialFocusRef : null
}
/>
{numStandardPoses == 0 && (
<PosePickerEmptyExplanation />
@ -267,7 +265,7 @@ function PosePicker({
selectedStyleId={altStyleId}
altStyles={altStyles}
onChange={onChangeStyle}
initialFocusRef={initialFocusRef}
initialFocusRef={tabIndex === 1 ? initialFocusRef : null}
/>
<StyleExplanation />
</TabPanel>
@ -424,14 +422,22 @@ function PosePickerTable({ poseInfos, onChange, initialFocusRef }) {
</tbody>
</table>
{poseInfos.unconverted.isAvailable && (
<PoseOption
poseInfo={poseInfos.unconverted}
onChange={onChange}
inputRef={poseInfos.unconverted.isSelected && initialFocusRef}
size="sm"
label="Unconverted"
<Flex
align="center"
justify="center"
gap="1"
marginTop="2"
/>
marginBottom="2"
>
<PoseOption
poseInfo={poseInfos.unconverted}
onChange={onChange}
inputRef={poseInfos.unconverted.isSelected && initialFocusRef}
size="sm"
label="Retired UC"
/>
<RetiredUCWarning isSelected={poseInfos.unconverted.isSelected} />
</Flex>
)}
</Box>
);
@ -624,6 +630,40 @@ function PosePickerEmptyExplanation() {
);
}
function RetiredUCWarning({ isSelected }) {
return (
<Popover placement="right" trigger="hover">
<PopoverTrigger>
<Box
as="button"
tabIndex="0"
aria-label="Warning"
cursor="help"
lineHeight="1"
opacity={isSelected ? "1" : "0.75"}
transform={isSelected ? "scale(1)" : "scale(0.8)"}
color={isSelected ? "yellow.500" : "inherit"}
transition="all 0.2s"
padding="1"
>
<WarningTwoIcon />
</Box>
</PopoverTrigger>
<PopoverContent
background="blackAlpha.800"
borderColor="blackAlpha.900"
color="white"
padding="2"
fontSize="sm"
>
"Unconverted" pets are no longer available on Neopets.com, and have been
replaced with the very similar Styles feature. We're just keeping this
as an archive!
</PopoverContent>
</Popover>
);
}
function StyleSelect({
selectedStyleId,
altStyles,
@ -748,10 +788,6 @@ function StyleExplanation() {
Styling Chamber
</Box>
. Not all items fit Alt Style pets. The pet's color doesn't have to match.
<SupportOnly>
<br />
WIP: Only Support staff see this tab for now! 💖
</SupportOnly>
</Box>
);
}
@ -889,7 +925,7 @@ function getIcon(pose) {
} else if (["SICK_MASC", "SICK_FEM"].includes(pose)) {
return twemojiSick;
} else if (pose === "UNCONVERTED") {
return twemojiSunglasses;
return twemojiHourglass;
} else {
return twemojiSmile;
}
@ -903,7 +939,7 @@ function getLabel(pose) {
} else if (pose === "SICK_MASC" || pose === "SICK_FEM") {
return "Sick";
} else if (pose === "UNCONVERTED") {
return "Classic UC";
return "Retired UC";
} else {
return "Default";
}

View file

@ -69,6 +69,7 @@ function useOutfitSaving(outfitState, dispatchToOutfit) {
speciesId: outfitState.speciesId,
colorId: outfitState.colorId,
pose: outfitState.pose,
altStyleId: outfitState.altStyleId,
wornItemIds: [...outfitState.wornItemIds],
closetedItemIds: [...outfitState.closetedItemIds],
})

View file

@ -447,6 +447,7 @@ function getOutfitStateFromOutfitData(outfit) {
speciesId: outfit.speciesId,
colorId: outfit.colorId,
pose: outfit.pose,
altStyleId: outfit.altStyleId,
wornItemIds: new Set(outfit.wornItemIds),
closetedItemIds: new Set(outfit.closetedItemIds),
};
@ -703,12 +704,6 @@ function buildOutfitQueryString(outfitState) {
color: colorId || "",
pose: pose || "",
});
for (const itemId of wornItemIds) {
params.append("objects[]", itemId);
}
for (const itemId of closetedItemIds) {
params.append("closet[]", itemId);
}
if (altStyleId != null) {
params.append("style", altStyleId);
}
@ -717,6 +712,12 @@ function buildOutfitQueryString(outfitState) {
// refers to "PetState", the database table name for pet appearances.
params.append("state", appearanceId);
}
for (const itemId of wornItemIds) {
params.append("objects[]", itemId);
}
for (const itemId of closetedItemIds) {
params.append("closet[]", itemId);
}
return params.toString();
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFE8B6" d="M21 18c0-2.001 3.246-3.369 5-6 2-3 2-10 2-10H8s0 7 2 10c1.754 2.631 5 3.999 5 6s-3.246 3.369-5 6c-2 3-2 10-2 10h20s0-7-2-10c-1.754-2.631-5-3.999-5-6z"/><path fill="#FFAC33" d="M20.999 24c-.999 0-2.057-1-2.057-2C19 20.287 19 19.154 19 18c0-3.22 3.034-4.561 4.9-7H12.1c1.865 2.439 4.9 3.78 4.9 7 0 1.155 0 2.289.058 4 0 1-1.058 2-2.058 2-2 0-3.595 1.784-4 3-1 3-1 7-1 7h16s0-4-1-7c-.405-1.216-2.001-3-4.001-3z"/><path fill="#3B88C3" d="M30 34c0 1.104-.896 2-2 2H8c-1.104 0-2-.896-2-2s.896-2 2-2h20c1.104 0 2 .896 2 2zm0-32c0 1.104-.896 2-2 2H8c-1.104 0-2-.896-2-2s.896-2 2-2h20c1.104 0 2 .896 2 2z"/></svg>

After

Width:  |  Height:  |  Size: 688 B

View file

@ -30,7 +30,9 @@ export function useDeleteOutfitMutation(options = {}) {
...options,
mutationFn: deleteOutfit,
onSuccess: (emptyData, id, context) => {
queryClient.invalidateQueries({ queryKey: ["outfits", String(id)] });
queryClient.invalidateQueries({
queryKey: ["outfits", String(id)],
});
if (options.onSuccess) {
options.onSuccess(emptyData, id, context);
}
@ -42,7 +44,9 @@ async function loadSavedOutfit(id) {
const res = await fetch(`/outfits/${encodeURIComponent(id)}.json`);
if (!res.ok) {
throw new Error(`loading outfit failed: ${res.status} ${res.statusText}`);
throw new Error(
`loading outfit failed: ${res.status} ${res.statusText}`,
);
}
return res.json().then(normalizeOutfit);
@ -54,6 +58,7 @@ async function saveOutfit({
speciesId,
colorId,
pose,
altStyleId,
wornItemIds,
closetedItemIds,
}) {
@ -65,6 +70,7 @@ async function saveOutfit({
color_id: colorId,
pose: pose,
},
alt_style_id: altStyleId,
item_ids: { worn: wornItemIds, closeted: closetedItemIds },
},
};
@ -91,7 +97,9 @@ async function saveOutfit({
}
if (!res.ok) {
throw new Error(`saving outfit failed: ${res.status} ${res.statusText}`);
throw new Error(
`saving outfit failed: ${res.status} ${res.statusText}`,
);
}
return res.json().then(normalizeOutfit);
@ -106,7 +114,9 @@ async function deleteOutfit(id) {
});
if (!res.ok) {
throw new Error(`deleting outfit failed: ${res.status} ${res.statusText}`);
throw new Error(
`deleting outfit failed: ${res.status} ${res.statusText}`,
);
}
}
@ -117,8 +127,11 @@ function normalizeOutfit(outfit) {
speciesId: String(outfit.species_id),
colorId: String(outfit.color_id),
pose: outfit.pose,
altStyleId: outfit.alt_style_id ? String(outfit.alt_style_id) : null,
wornItemIds: (outfit.item_ids?.worn || []).map((id) => String(id)),
closetedItemIds: (outfit.item_ids?.closeted || []).map((id) => String(id)),
closetedItemIds: (outfit.item_ids?.closeted || []).map((id) =>
String(id),
),
creator: outfit.user ? { id: String(outfit.user.id) } : null,
createdAt: outfit.created_at,
updatedAt: outfit.updated_at,

View file

@ -4,6 +4,7 @@ class Outfit < ApplicationRecord
class_name: 'ItemOutfitRelationship'
has_many :worn_items, through: :worn_item_outfit_relationships, source: :item
belongs_to :alt_style, optional: true
belongs_to :pet_state, optional: true # We validate presence below!
belongs_to :user, optional: true
@ -82,7 +83,8 @@ class Outfit < ApplicationRecord
def as_json(more_options={})
serializable_hash(
only: [:id, :name, :pet_state_id, :starred, :created_at, :updated_at],
only: [:id, :name, :pet_state_id, :starred, :created_at, :updated_at,
:alt_style_id],
methods: [:color_id, :species_id, :pose, :item_ids, :user]
)
end

View file

@ -1,12 +1,12 @@
- title "Styling Studio"
%p
We're getting set up with the new NC Pet Styles! They're not ready in the app
yet, but here's what we have so far!
Here's all the new NC Pet Styles we have! They're available in the app too,
by opening the emotion picker and clicking the "Styles" tab.
%p
If you have one we don't, please model it by entering your pet's name on the
homepage! Thank you! 💖
If you have an Alt Style we don't, please model it by entering your pet's
name on the homepage! Thank you! 💖
%p
Also, heads-up: Style tokens are pretty different from normal wearables, so

View file

@ -3,11 +3,11 @@
= advertise_campaign_progress @campaign
.notice
%strong Happy NC UC day!
We're working on Styling Studio support,
= link_to("here's what we have so far", alt_styles_path) + "!"
%strong Alt styles are ready now!
You can find them by opening the emotion picker, then clicking "Styles".
%br
Thank you for helping us model the new styles, we appreciate it lots!!! 💖
= link_to("Here's our reference page, too!", alt_styles_path)
Thanks again for all your help, let us know how it works for you! 💖
%p#pet-not-found.alert= t 'pets.load.not_found'

View file

@ -0,0 +1,5 @@
class AddAltStyleIdToOutfits < ActiveRecord::Migration[7.1]
def change
add_reference :outfits, :alt_style, null: true, foreign_key: true
end
end

View file

@ -10,13 +10,13 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_01_29_114639) do
create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
ActiveRecord::Schema[7.1].define(version: 2024_02_01_134440) do
create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.integer "species_id", null: false
t.integer "color_id", null: false
t.integer "body_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.index ["color_id"], name: "index_alt_styles_on_color_id"
t.index ["species_id"], name: "index_alt_styles_on_species_id"
end
@ -125,11 +125,11 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_29_114639) do
t.index ["outfit_id", "is_worn"], name: "index_item_outfit_relationships_on_outfit_id_and_is_worn"
end
create_table "item_translations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
create_table "item_translations", id: :integer, charset: "latin1", collation: "latin1_swedish_ci", force: :cascade do |t|
t.integer "item_id"
t.string "locale"
t.string "name"
t.text "description", size: :medium
t.text "description"
t.string "rarity"
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
@ -191,6 +191,8 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_29_114639) do
t.string "image"
t.string "image_layers_hash"
t.boolean "image_enqueued", default: false, null: false
t.bigint "alt_style_id"
t.index ["alt_style_id"], name: "index_outfits_on_alt_style_id"
t.index ["pet_state_id"], name: "index_outfits_on_pet_state_id"
t.index ["user_id"], name: "index_outfits_on_user_id"
end
@ -314,4 +316,5 @@ ActiveRecord::Schema[7.1].define(version: 2024_01_29_114639) do
add_foreign_key "alt_styles", "colors"
add_foreign_key "alt_styles", "species"
add_foreign_key "outfits", "alt_styles"
end